Compare commits

...

79 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
8ce24ce5f8 Display listbox text 2020-04-01 19:45:57 +01:00
7935f78acc Add a partial listbox implementation 2020-04-01 01:38:42 +01:00
31da40e772 Move inventorySelect to its own file 2020-04-01 00:03:43 +01:00
c1925697c9 Refactor inventorySelect to build it hierarchically 2020-03-31 23:45:11 +01:00
2ae3611d7f Allow menu records to be processed hierarchically by the UI driver
Nothing is actually processed in this way yet, but there is a new
assertion forbidding certain types of records from having children.

Because of this new assertion, our menutype tweaks must be moved up a
layer into internal/menus. They fit better there anyway.
2020-03-31 23:29:43 +01:00
7586b90f8a Update go.mod 2020-03-31 22:31:10 +01:00
b5a722eef0 Make a start on font rendering
I was hopeful I could use ebiten/text, but font.Face doesn't seem set
up for fixed-colour fonts.
2020-03-30 00:15:19 +01:00
27fbccdc5f Get the briefing menu linked up
Yes, that means hypertext is now a clickable.
2020-03-27 02:16:54 +00:00
c090fd32e9 Link various screens accessible from the bridge
This kind of linking is starting to creak...
2020-03-27 02:07:28 +00:00
316db89148 Get the bridge door animations running 2020-03-27 00:54:57 +00:00
79bfab7d6b We can reach the bridge \o/ 2020-03-26 23:35:34 +00:00
e4ce932324 Display overlay text in the UI
We still have fonts to do, so this is very ugly, but it at least shows
*something* on the screen now.
2020-03-26 22:09:26 +00:00
a0fd653c24 Add some information about sound 2020-03-26 20:47:05 +00:00
3d3a55af9d Break flow out of ordoor 2020-03-25 02:12:17 +00:00
4eb4b6e69f More menu navigation 2020-03-25 00:48:09 +00:00
7824396c24 Add stubs for unknown widget types 2020-03-25 00:23:28 +00:00
b986359047 Draw overlays 2020-03-24 23:26:21 +00:00
d376d9850c Wire the sliders into the config file
Not yet the game itself. That's still TODO.
2020-03-24 23:11:37 +00:00
20ad9ae6f8 Add a slider UI widget
I'm not too happy with how I have to configure the step for each one
separately, but it's the best I can do at the moment.
2020-03-24 22:33:26 +00:00
69971b2825 Rework the UI framework
Interface is now Driver, and Widget is now a set of interfaces with a
struct per widget type. This should make it easier to add other types.
2020-03-24 20:21:55 +00:00
bcee07e8f7 Make animations work in the options screen 2020-03-23 00:33:29 +00:00
c67ee206cd Implement hypertext (badly) 2020-03-22 23:29:40 +00:00
91 changed files with 7334 additions and 2541 deletions

17
.gitignore vendored
View File

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

View File

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

184
README.md
View File

@@ -1,32 +1,107 @@
# Ordoor # Ordoor
Ordoor is an **unofficial** [game engine recreation](https://en.wikipedia.org/wiki/Game_engine_recreation) 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 Four games are known to have been published for this engine:
the current publisher of this game; [you can purchase it here](https://www.gog.com/game/warhammer_40000_chaos_gate).
"Warhammer 40,000" is a trademark of Games Workshop, and the game data used by * [Wages of War: The Business of Battle](https://en.wikipedia.org/wiki/Wages_of_War) (1996)
Ordoor contains Games Workshop intellectual property. I am confident that this * [Soldiers At War](https://en.wikipedia.org/wiki/Soldiers_at_War) (1998)
project uses all those things in accordance with the * [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)
[Intellectual Property Policy](https://www.games-workshop.com/en-GB/Intellectual-Property-Policy) * [Avalon Hill's Squad Leader](https://en.wikipedia.org/wiki/Avalon_Hill%27s_Squad_Leader) (2000)
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. 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 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 ## Current status
### Chaos Gate
Some of the original file formats are either partially or fully decoded. Maps, 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 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 (with a preprocessing step). Some UI tookit work is done. No game mechanics are
implemented yet. 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 ## Building from source
I'm writing code in Go at the moment, so you'll need to have a Go runtime 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 $ go version
@@ -44,28 +119,25 @@ Debian:
You can then run `make all` in the source tree to get the binaries that are You can then run `make all` in the source tree to get the binaries that are
present at the moment. present at the moment.
Place your WH40K: Chaos Gate installation in `./orig` to benefit from automatic ## Configuring
path defaults. Otherwise, point to it with `-game-path`
The `view-map` binary attempts to render a map, and is the current focus of Since we support multiple games, a fair bit of configuration is required. Copy
effort. Once I can render a whole map, including pre-placed characters (cultist `config.toml.example` to `config.toml` and edit it to your requirements. The
scum), things can start to get more interesting. `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 The various games all use snapshots of the original engine at different points
coordinate: floor, centre, left, and right) are rendered fine, and each Z level in time, and specify a lot in code that we need to specify in data. That should
looks good. There are a few minor artifacts here and there. 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 ## Running
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.
To run: To run:
``` ```
$ make view-map $ make view-map
$ ./view-map -map Chapter01 $ ./bin/view-map -map Chapter01
``` ```
Looks like this: 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 Use the arrow keys to scroll around the map, the mouse wheel to zoom, and the
`1` - `7` keys to change Z level. `1` - `7` keys to change Z level.
Dependency management uses `go mod`, so ensure you have at least Go 1.11. Menus / UI widgets have fairly good support now; you can use the `view-menu`
binary to inspect them:
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:
``` ```
make view-menu 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 ## Sound
@@ -110,31 +184,3 @@ $ ./scripts/convert-wav ./orig/Wav
As with video playback, the ambition is to *eventually* remove this dependency As with video playback, the ambition is to *eventually* remove this dependency
and operate on the unmodified files instead. 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 ( import (
"flag" "flag"
"fmt" "fmt"
"image/color"
"log" "log"
"path/filepath" "path/filepath"
"strings" "strings"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/fonts" "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/maps"
"code.ur.gs/lupine/ordoor/internal/menus" "code.ur.gs/lupine/ordoor/internal/menus"
"code.ur.gs/lupine/ordoor/internal/palettes"
"code.ur.gs/lupine/ordoor/internal/sets" "code.ur.gs/lupine/ordoor/internal/sets"
) )
var ( var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation") configFile = flag.String("config", "config.toml", "Config file")
skipObj = flag.Bool("skip-obj", true, "Skip loading .obj files") 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() { func main() {
flag.Parse() flag.Parse()
loadData() cfg, err := config.Load(*configFile, *engine)
if err != nil {
if !*skipObj { log.Fatalf("Failed to load config: %v", err)
loadObj() }
engine := cfg.DefaultEngine()
gamePath := engine.DataDir
palette, ok := palettes.Get(engine.Palette)
if !ok {
log.Fatalf("Unknown palette name: %v", engine.Palette)
} }
loadMapsFrom("Maps") loadData(filepath.Join(gamePath, "Data"))
loadMapsFrom("MultiMaps")
loadSets() if !*skipObj {
loadMenus() loadObj(filepath.Join(gamePath, "Obj"))
loadFonts() }
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() { func loadData(dataPath string) {
dataPath := filepath.Join(*gamePath, "Data")
accountingPath := filepath.Join(dataPath, "Accounting.dat") accountingPath := filepath.Join(dataPath, "Accounting.dat")
genericDataPath := filepath.Join(dataPath, "GenericData.dat")
aniObDefPath := filepath.Join(dataPath, "AniObDef.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) i18nPath := filepath.Join(dataPath, data.I18nFile)
log.Printf("Loading %s...", accountingPath) log.Printf("Loading %s...", accountingPath)
@@ -73,11 +92,15 @@ func loadData() {
} }
log.Printf("%s: len=%v", i18nPath, i18n.Len()) 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() { func loadObj(objDataPath string) {
objDataPath := filepath.Join(*gamePath, "Obj")
// TODO: Obj/cpiece.rec isn't loaded by this. Do we need it? How do we know? // TODO: Obj/cpiece.rec isn't loaded by this. Do we need it? How do we know?
log.Printf("Loading %s...", objDataPath) log.Printf("Loading %s...", objDataPath)
objects, err := data.LoadObjects(objDataPath) objects, err := data.LoadObjects(objDataPath)
@@ -107,8 +130,7 @@ func loadObj() {
} }
} }
func loadMapsFrom(part string) { func loadMapsFrom(mapsPath string) {
mapsPath := filepath.Join(*gamePath, part)
log.Printf("Loading maps from %s", mapsPath) log.Printf("Loading maps from %s", mapsPath)
gameMaps, err := maps.LoadGameMaps(mapsPath) gameMaps, err := maps.LoadGameMaps(mapsPath)
@@ -118,20 +140,20 @@ func loadMapsFrom(part string) {
log.Printf("Maps in %s:", mapsPath) log.Printf("Maps in %s:", mapsPath)
for key, gameMap := range gameMaps { for key, gameMap := range gameMaps {
rect := gameMap.Rect()
hdr := gameMap.Header hdr := gameMap.Header
fmt.Printf( fmt.Printf(
" * `%s`: IsCampaignMap=%v W=%v:%v L=%v:%v SetName=%s\n", " * `%s`: IsCampaignMap=%v W=%v:%v L=%v:%v SetName=%s\n",
key, key,
hdr.IsCampaignMap, hdr.IsCampaignMap,
hdr.MinWidth, hdr.MaxWidth, rect.Min.X, rect.Max.X,
hdr.MinLength, hdr.MaxLength, rect.Min.Y, rect.Max.Y,
string(hdr.SetName[:]), string(hdr.SetName[:]),
) )
} }
} }
func loadSets() { func loadSets(setsPath string) {
setsPath := filepath.Join(*gamePath, "Sets")
log.Printf("Loading sets from %s", setsPath) log.Printf("Loading sets from %s", setsPath)
mapSets, err := sets.LoadSets(setsPath) mapSets, err := sets.LoadSets(setsPath)
@@ -147,38 +169,38 @@ func loadSets() {
} }
} }
func loadMenus() { func loadMenus(menusPath string, palette color.Palette) {
menusPath := filepath.Join(*gamePath, "Menu") log.Printf("Loading menus from %s", menusPath)
menus, err := menus.LoadMenus(menusPath) menus, err := menus.LoadMenus(menusPath, palette)
if err != nil { if err != nil {
log.Fatalf("Failed to parse %s/*.mnu as menus: %v", menusPath, err) log.Fatalf("Failed to parse %s/*.mnu as menus: %v", menusPath, err)
} }
for _, menu := range menus { for _, menu := range menus {
fmt.Printf(" * `%s`: objects=%v fonts=%v\n", menu.Name, menu.ObjectFiles, menu.FontNames) 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) { 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 { if !record.Active {
content = "(" + content + ")" content = "(" + content + ")"
} }
fmt.Printf("%s* %s\n", strings.Repeat(" ", depth), content) fmt.Printf("%s* %s\n", strings.Repeat(" ", depth), content)
for _, child := range record.Children {
displayRecord(child, depth+1)
}
} }
func loadFonts() { func loadFonts(fontsPath string) {
fontsPath := filepath.Join(*gamePath, "Fonts") log.Printf("Loading fonts from %s", fontsPath)
fonts, err := fonts.LoadFonts(fontsPath) fonts, err := fonts.LoadFonts(fontsPath)
if err != nil { if err != nil {
@@ -189,3 +211,16 @@ func loadFonts() {
fmt.Printf(" * `%s`: obj=%v entries=%v\n", font.Name, font.ObjectFile, font.Entries()) 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

@@ -1,19 +1,25 @@
package main package main
import ( import (
"flag"
"log" "log"
"os" "os"
"code.ur.gs/lupine/ordoor/internal/ordoor" "code.ur.gs/lupine/ordoor/internal/ordoor"
) )
var (
winX = flag.Int("win-x", 0, "Pre-scaled window X dimension override")
winY = flag.Int("win-y", 0, "Pre-scaled window Y dimension override")
)
func main() { func main() {
configFile := "config.toml" configFile := "config.toml"
if len(os.Args) == 2 { if len(os.Args) == 2 {
configFile = os.Args[1] configFile = os.Args[1]
} }
if err := ordoor.Run(configFile); err != nil { if err := ordoor.Run(configFile, *winX, *winY); err != nil {
log.Fatalf(err.Error()) log.Fatalf(err.Error())
} }

View File

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

119
cmd/view-font/main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"flag"
"image"
"log"
"math"
"os"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/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")
fontName = flag.String("font", "", "Name of a font, e.g., basfont12")
txt = flag.String("text", "Test string", "Text to render")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
)
type env struct {
font *assetstore.Font
step int
state state
lastState state
}
type state struct {
zoom float64
origin image.Point
}
func main() {
flag.Parse()
if *configFile == "" || *fontName == "" {
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.Fatal(err)
}
font, err := assets.Font(*fontName)
if err != nil {
log.Fatalf("Couldn't load font %s: %v", *fontName, err)
}
state := state{zoom: 8.0}
env := &env{
font: font,
state: state,
lastState: state,
}
win, err := ui.NewWindow(env, "View Font: "+*fontName, *winX, *winY)
if err != nil {
log.Fatal("Couldn't create window: %v", err)
}
win.OnMouseWheel(env.changeZoom)
// Main thread now belongs to ebiten
if err := win.Run(); err != nil {
log.Fatal(err)
}
}
func (e *env) Update(screenX, screenY int) error {
if e.step == 0 || e.lastState != e.state {
log.Printf("new state: zoom=%.2f", e.state.zoom)
}
e.step += 1
e.lastState = e.state
return nil
}
func (e *env) Draw(screen *ebiten.Image) error {
xOff := 0
for _, r := range *txt {
glyph, err := e.font.Glyph(r)
if err != nil {
return err
}
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(xOff), 0)
op.GeoM.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
if err := screen.DrawImage(glyph.Image, op); err != nil {
return err
}
xOff += glyph.Rect.Dx()
}
return nil
}
func (e *env) changeZoom(_, y float64) {
// Zoom in and out with the mouse wheel
e.state.zoom *= math.Pow(1.2, y)
}

View File

@@ -2,74 +2,57 @@ package main
import ( import (
"flag" "flag"
"image"
"log" "log"
"math" "math"
"os" "os"
"sort"
"github.com/hajimehoshi/ebiten" "github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore" "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" "code.ur.gs/lupine/ordoor/internal/ui"
) )
var ( var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation") configFile = flag.String("config", "config.toml", "Config file")
gameMap = flag.String("map", "", "Name of a map, e.g., Chapter01") 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") winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension") winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
) )
type env struct { type env struct {
assets *assetstore.AssetStore scenario *scenario.Scenario
area *assetstore.Map
step int
state state
lastState state
}
type state struct {
zoom float64
origin image.Point
zIdx int
} }
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || *gameMap == "" { if *configFile == "" || *gameMap == "" {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
assets, err := assetstore.New(*gamePath) cfg, err := config.Load(*configFile, *engine)
if err != nil { 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 { 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 scenario, err := scenario.NewScenario(assets, *gameMap)
if err := area.LoadSprites(); err != nil { if err != nil {
log.Fatal("Eager-loading sprites failed: %v", err) 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{ env := &env{
area: area, scenario: scenario,
assets: assets,
state: state,
lastState: state,
} }
win, err := ui.NewWindow(env, "View Map "+*gameMap, *winX, *winY) win, err := ui.NewWindow(env, "View Map "+*gameMap, *winX, *winY)
@@ -77,168 +60,56 @@ func main() {
log.Fatal("Couldn't create window: %v", err) 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)) for i := 0; i <= 6; i++ {
win.OnKeyUp(ebiten.KeyRight, env.changeOrigin(+64, +0)) win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i))
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))
} }
win.OnMouseClick(env.showCellData)
win.OnMouseWheel(env.changeZoom)
if err := win.Run(); err != nil { if err := win.Run(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
func (e *env) Update(screenX, screenY int) error { func (e *env) Update(screenX, screenY int) error {
if e.step == 0 || e.lastState != e.state { return e.scenario.Update(screenX, screenY)
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
} }
func (e *env) Draw(screen *ebiten.Image) error { func (e *env) Draw(screen *ebiten.Image) error {
// Bounds clipping return e.scenario.Draw(screen)
// 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
} }
func (e *env) changeOrigin(byX, byY int) func() { func (e *env) changeOrigin(byX, byY int) func() {
return func() { return func() {
e.state.origin.X += byX e.scenario.Viewpoint.X += byX
e.state.origin.Y += byY e.scenario.Viewpoint.Y += byY
} }
} }
func (e *env) changeZoom(_, y float64) { func (e *env) changeZoom(_, byY float64) {
// Zoom in and out with the mouse wheel e.scenario.Zoom *= math.Pow(1.2, byY)
e.state.zoom *= math.Pow(1.2, y)
} }
func (e *env) setZIdx(to int) func() { func (e *env) setZIdx(to int) func() {
return func() { return func() {
e.state.zIdx = to e.scenario.ZIdx = to
} }
} }
const ( func (e *env) showCellData() {
cellWidth = 64 screenX, screenY := ebiten.CursorPosition()
cellHeight = 64 viewX, viewY := e.scenario.Viewpoint.X+screenX, e.scenario.Viewpoint.Y+screenY
)
// Doesn't take the camera or Z level into account log.Printf("Click registered at (%d,%d) screen, (%d,%d) virtual", screenX, screenY, viewX, viewY)
func cellToPix(pt image.Point) image.Point {
return image.Pt(
(pt.X-pt.Y)*cellWidth,
(pt.X+pt.Y)*cellHeight/2,
)
}
// Doesn't take the camera or Z level into account cell, pos := e.scenario.CellAtCursor()
func pixToCell(pt image.Point) image.Point { log.Printf("Viewpoint: %#+v z=%v", e.scenario.Viewpoint, e.scenario.ZIdx)
return image.Pt( log.Printf("Cell under cursor: (%.2f,%.2f,%d): %#+v", pos.X, pos.Y, pos.Z, cell)
pt.Y/cellHeight+pt.X/(cellWidth*2),
pt.Y/cellHeight-pt.X/(cellWidth*2),
)
} }

View File

@@ -5,27 +5,43 @@ import (
"log" "log"
"os" "os"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore" "code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui" "code.ur.gs/lupine/ordoor/internal/ui"
) )
var ( 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") menuName = flag.String("menu", "", "Name of a menu, e.g. Main")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension") winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension") winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
) )
type dlg struct {
driver *ui.Driver
list []string
pos int
}
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || *menuName == "" { if *configFile == "" || *menuName == "" {
flag.Usage() flag.Usage()
os.Exit(1) 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -35,17 +51,48 @@ func main() {
log.Fatalf("Couldn't load menu %s: %v", *menuName, err) log.Fatalf("Couldn't load menu %s: %v", *menuName, err)
} }
iface, err := ui.NewInterface(menu) driver, err := ui.NewDriver(assets, menu)
if err != nil { if err != nil {
log.Fatalf("Couldn't initialize interface: %v", err) log.Fatalf("Couldn't initialize interface: %v", err)
} }
win, err := ui.NewWindow(iface, "View Menu: "+*menuName, *winX, *winY) win, err := ui.NewWindow(driver, "View Menu: "+*menuName, *winX, *winY)
if err != nil { if err != nil {
log.Fatal("Couldn't create window: %v", err) 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 { if err := win.Run(); err != nil {
log.Fatal(err) 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 { func (e *env) Draw(screen *ebiten.Image) error {
gameMap := e.gameMap gameMap := e.gameMap
imd, err := ebiten.NewImage( rect := gameMap.Rect()
int(gameMap.MaxWidth), imd, err := ebiten.NewImage(rect.Dx(), rect.Dy(), ebiten.FilterDefault)
int(gameMap.MaxLength),
ebiten.FilterDefault,
)
if err != nil { if err != nil {
return err return err
} }
for y := int(gameMap.MinLength); y < int(gameMap.MaxLength); y++ { for y := int(rect.Min.Y); y < int(rect.Max.Y); y++ {
for x := int(gameMap.MinWidth); x < int(gameMap.MaxWidth); x++ { for x := int(rect.Min.X); x < int(rect.Max.X); x++ {
cell := gameMap.Cells.At(x, y, int(e.state.zIdx)) cell := gameMap.Cells.At(x, y, int(e.state.zIdx))
imd.Set(x, y, makeColour(&cell, e.state.cellIdx)) imd.Set(x, y, makeColour(&cell, e.state.cellIdx))
} }

View File

@@ -10,13 +10,17 @@ import (
"github.com/hajimehoshi/ebiten" "github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore" "code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui" "code.ur.gs/lupine/ordoor/internal/ui"
) )
var ( var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation") configFile = flag.String("config", "config.toml", "Config file")
objFile = flag.String("obj-file", "", "Path of an .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ") engine = flag.String("engine", "", "Override engine to use")
objName = flag.String("obj-name", "", "Name of an .obj file, e.g. TZEENTCH")
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") winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension") winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
@@ -24,6 +28,7 @@ var (
type env struct { type env struct {
obj *assetstore.Object obj *assetstore.Object
spr *assetstore.Sprite
step int step int
state state state state
@@ -40,14 +45,19 @@ type state struct {
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || (*objName == "" && *objFile == "") { if *configFile == "" || (*objName == "" && *objFile == "") {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
assets, err := assetstore.New(*gamePath) cfg, err := config.Load(*configFile, *engine)
if err != nil { 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 var obj *assetstore.Object
@@ -61,8 +71,9 @@ func main() {
} }
state := state{ state := state{
zoom: 6.0, zoom: 6.0,
origin: image.Point{0, 0}, origin: image.Point{0, 0},
spriteIdx: *sprIdx,
} }
env := &env{ env := &env{
@@ -92,10 +103,17 @@ func main() {
func (e *env) Update(screenX, screenY int) error { func (e *env) Update(screenX, screenY int) error {
if e.step == 0 || e.lastState != e.state { if e.step == 0 || e.lastState != e.state {
sprite, err := e.obj.Sprite(e.state.spriteIdx)
if err != nil {
return err
}
e.spr = sprite
log.Printf( log.Printf(
"new state: sprite=%d/%d zoom=%.2f, origin=%+v", "new state: sprite=%d/%d bounds=%+#v zoom=%.2f, origin=%+v",
e.state.spriteIdx, e.state.spriteIdx,
e.obj.NumSprites, e.obj.NumSprites,
e.spr.Rect,
e.state.zoom, e.state.zoom,
e.state.origin, e.state.origin,
) )

View File

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

View File

@@ -1,6 +1,22 @@
[ordoor] video_player = ["mpv", "--no-config", "--keep-open=no", "--force-window=no", "--no-border", "--no-osc", "--fullscreen", "--no-input-default-bindings"]
data_dir = "./orig"
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] [options]
play_movies = true 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,35 +6,33 @@ remake.
## Filesystem layout ## Filesystem layout
* `Anim/` * [✓] [`Anim/`](obj.md#WarHammer.ani)
* `WarHammer.ani` # Doesn't seem to be a RIFF file. 398M so very important. * [`WarHammer.ani`](obj.md#WarHammer.ani)
* 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
* [`Assign/`](obj.md#assign) * [`Assign/`](obj.md#assign)
* `*.asn` # Unknown, seems to be related to .obj files * `*.asn` # Specify properties for frames in .obj files
* `Cursor/` * `Cursor/`
* `*.ani` # RIFF data * `*.ani` # RIFF data, standard ANI format \o/
* `*.cur` # Presumably standard windows-format non-animated cursors * [`Cursors.cur`](obj.md) # `obj` file containing pointers and drag elements
* `Data/` * `Data/`
* `*.dat` # plaintext files defining properties of objects. No single format * `*.dat` # plaintext files defining properties of objects. No single format
* **PARSED** * **PARSED**
* `Accounting.dat` # key = value => internal/data/accounting.go * `Accounting.dat` # key = value => internal/data/accounting.go
* `AniObjDef.dat` # animated object definitions * `AniObjDef.dat` # animated object definitions
* `GenericData.dat` # Generic Game Settings * `GenericData.dat` # Generic Game Settings
* [`HasAction.dat`](ani.md) # "Are there animation for each of the character" - list of booleans
* **TODO** * **TODO**
* `ChaNames.dat` # list of character names * `ChaNames.dat` # list of character names
* `Coordinates.dat` # Weapon Firing Coordinates * `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 * `Defs.dat` # defines properties for objects and tiles, each of which seems to have an id
* `GDestroy.dat` # table of what destroys what? * `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? * `MiniMap.dat` # lots of seemingly random numbers. IDs?
* `MissionBriefing.dat` # Contains all Campaign Mission Briefing Text. Sections: "CAMPAIGN MISSION X ... END CAMPAIGN MISSION X" * `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 * `PWeight.dat` # Personality weights for the individual character types
* `Random_AI.dat` # contains the percentage of the different AI types for each of the different Chaos Squad Types * `Random_AI.dat` # contains the percentage of the different AI types for each of the different Chaos Squad Types
* `RandomPlanets.dat` # Campaign Primary and Secondary Objectives * `RandomPlanets.dat` # Campaign Primary and Secondary Objectives
* `Sounds.dat` # Sound Effect Data * [`Sounds.dat`](sound.md) # Sound Effect Data
* `SpellDef.dat` # SPELL DEFINITIONS * `SpellDef.dat` # SPELL DEFINITIONS
* `StdWeap.dat` # SQUAD STANDARD WEAPONS * `StdWeap.dat` # SQUAD STANDARD WEAPONS
* `Ultnames.dat` # List of names for ultramarines * `Ultnames.dat` # List of names for ultramarines
@@ -42,7 +40,6 @@ remake.
* `WeapDef.dat` # Weapon definitions * `WeapDef.dat` # Weapon definitions
* **PROBABLY NOT NEEDED** * **PROBABLY NOT NEEDED**
* `BugHunt.dat` # Contains SMF text for Bug hunt random missions * `BugHunt.dat` # Contains SMF text for Bug hunt random missions
* `Credits.dat` # list of credits
* `GenArm.dat` # "Random campaign armory levels - space marine" * `GenArm.dat` # "Random campaign armory levels - space marine"
* `HeroArm.dat` # "Random campaign armory levels - Veteran" * `HeroArm.dat` # "Random campaign armory levels - Veteran"
* `MHeroArm.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" * `SpArm.dat` # "RANDOM CAMPAIGN ARMORY LEVELS - Veteran"
* `VetArm.dat` # "RANDOM CAMPAIGN ARMORY LEVELS - Veteran" * `VetArm.dat` # "RANDOM CAMPAIGN ARMORY LEVELS - Veteran"
* `*.chk` # checksums? Mentions all the .dat files * `*.chk` # checksums? Mentions all the .dat files
* `*.cyc` # ColorCycle DataFile. * `Cycle.cyc` # ColorCycle DataFile.
* `*.dta` # localized strings and things
* `Encyclopedia.dta` # encyclopedia entries * `Encyclopedia.dta` # encyclopedia entries
* `KeyMap.dta` # unknown * `keymap.dta` # unknown
* `Keymap.dta` # unknown
* `USEng.dta` # Localized strings * `USEng.dta` # Localized strings
* `EquipmentMenuData` # gzip-compressed, presumably to do with (initial?) squad configuration * `EquipmentMenuData` # gzip-compressed, presumably to do with (initial?) squad configuration
* `Filters/` * `Filters/`
* `wh40k.flt` # Audio filter(s?) * `wh40k.flt` # Audio filter(s?)
* [✓] [`Fonts/`](fonts.md) * [✓] [`Fonts/`](fonts.md)
* `cboxfont` # ??? * `cboxfont` # ???
* `*.fnt` * [`*.fnt`](fonts.md)
* `*.spr` * [`*.spr`](obj.md) # `obj` file
* `Idx/` * [ ] [`Idx/`](ani.md)
* `WarHammer.idx` # unknown, 1.8M * [`WarHammer.idx`](ani.md) # unknown, 1.8M
* [`Maps/`](maps.md) * [`Maps/`](maps.md)
* `*.MAP` * [`*.MAP`](maps.md)
* `*.TXT` * [`*.TXT`](maps.md)
* [`Menu/`](mnu.md) - UI element definitions * [`Menu/`](mnu.md) - UI element definitions
* `*.mni` * [`*.mni`](mnu.md) # Menu include file
* `*.mnu` * [`*.mnu`](mnu.md)
* [`*.obj`](obj.md) * [`*.obj`](obj.md)
* `Misc/` * `Misc/`
* `occlusio.lis` # plain text, presumably occlusion mappings? * `occlusio.lis` # plain text, presumably occlusion mappings?
* [`MultiMaps/`](maps.md#multimaps) * [`MultiMaps/`](maps.md#multimaps)
* `*.MAP` * [`*.MAP`](maps.md)
* `*.TXT` * [`*.TXT`](maps.md)
* [✓] [`Obj/`](obj.md) * [`Obj/`](obj.md)
* `*.obj` * [ ] `cpiece.rec` # "Rects for various cursor piece types..."
* [✓] [`*.obj`](obj.md)
* [✓] `Pic/` * [✓] `Pic/`
* `*.pcx` # Standard .pcx format * `*.pcx` # Standard .pcx format
* `RandomMaps/` * `RandomMaps/`
@@ -91,13 +87,13 @@ remake.
* `*.txt` # Seems to be a copy of one of Maps/*.txt * `*.txt` # Seems to be a copy of one of Maps/*.txt
* [✓] [`Sets/`](sets.md) * [✓] [`Sets/`](sets.md)
* `Data.chk` * `Data.chk`
* `*.set` * [`*.set`](sets.md)
* [✓] `SMK/` * [✓] `SMK/`
* `*.smk` # Videos: RAD Game Tools Smacker Multimedia version 2 * `*.smk` # Videos: RAD Game Tools Smacker Multimedia version 2
* `Sounds/` * [ ] [`Sounds/`](sound.md)
* `wh40k.ds` # 0xffffffff then a list of .wav file names. Some sort of index? * [`wh40k.ds`](sound.md)
* [✓] `Wav/` * [ ] [`Wav/`](sound.md)
* `*.wav` * [`*.wav`](sound.md)
Phew. Phew.
@@ -107,7 +103,7 @@ Phew.
* *Almost everything* seems to be in a data file somewhere. Helpful! * *Almost everything* seems to be in a data file somewhere. Helpful!
* `make loader` creates a `load` binary that will try to load various bits * `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 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 ## Cross-links / associations
@@ -118,5 +114,5 @@ Phew.
* [`Maps/*.map`](maps.md): * [`Maps/*.map`](maps.md):
* [`Maps/*.txt`](maps.md#associated-txt-file) * [`Maps/*.txt`](maps.md#associated-txt-file)
* [`Sets/*.set`](sets.md) * [`Sets/*.set`](sets.md)
* [`Sounds/wh40k.ds`](sounds.md) * [`Sounds/wh40k.ds`](sound.md)
* `Wav/*.wav` * `Wav/*.wav`

View File

@@ -392,8 +392,11 @@ well-aligned amount.
Investigation has so far suggested the following: Investigation has so far suggested the following:
* `Cell[0]` seems related to doors and canisters. Observed: * `Cell[0]` seems related to doors and canisters. Observed:
* Nothing special: 0x38
* ???: 0x39
* Imperial crate: 0x28 * Imperial crate: 0x28
* Door: 0xB8 * Door: 0xB8
* `Cell[1]` seems related to special placeables (but not triggers). Bitfield. Observed: * `Cell[1]` seems related to special placeables (but not triggers). Bitfield. Observed:
* 0x01: Reactor * 0x01: Reactor
* 0x20: Door or door lock? * 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[7]` Object 2 (Right) Area (Sets/*.set lookup)
* `Cell[6]` Object 2 (Right) Sprite + active flag * `Cell[6]` Object 2 (Right) Sprite + active flag
* `Cell[9]` Object 3 (Center) Area (Sets/*.set lookup) * `Cell[9]` Object 3 (Center) Area (Sets/*.set lookup)
* `Cell[10]` Object 3 (Right) Sprite + active flag * `Cell[10]` Object 3 (Center) Sprite + active flag
* `Cell[11]` all 255? * `Cell[11]` all 255? Vehicle?
* `Cell[12]` all 0? * `Cell[12]` all 0?
* `Cell[13]` all 0? * `Cell[13]` all 0?
* `Cell[14]` 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 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` 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 ................ 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 Relative offsets from the start of the trailer, we have:
assume these are all a fixed number of fixed-size records when looking into it.
| 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 It looks like we just interpolate the named file into the text when we come
across one of these lines. 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 The types seem to refer to different types of UI widget. Here's a list of unique
values: values:
@@ -142,47 +162,49 @@ values:
| Value | Meaning | | Value | Meaning |
|-------|---------| |-------|---------|
| 0 | Background | | 3 | `Button` |
| 1 | Logical menu grouping? | | 30 | `DoorHotspot1` |
| 2 | ? | | 31 | `DoorHotspot2` |
| 3 | Standard button? | | 40 | `LineKbd` |
| 30 | Equipment? | | 41 | `LineBriefing` |
| 31 | "Character helmet" / "Slot" | | 45 | `Thumb` |
| 40 | "X Line Y" | | 50 | `InvokeButton` |
| 41 | "X Line Y" | | 60 | `DoorHotspot3` |
| 45 | ? | | 61 | `Overlay` |
| 45,10,11,9 | ? | | 70 | `Hypertext` |
| 45,11,12,10 | ? | | 91 | `Checkbox` |
| 45,14,15,13 | ? | | 100 | `EditBox` |
| 45,17,18,16 | ? | | 110 | `InventorySelect` |
| 45,3,4,2 | ? | | 120 | `RadioButton` |
| 45,5,6,4 | ? | | 200 | `DropdownButton` |
| 45,6,7,5 | ? | | 205 | `ComboBoxItem` |
| 45,7,8,6 | ? | | 220 | `AnimationSample` |
| 45,8,9,7 | ? | | 221 | `AnimationHover` |
| 45,9,10,8 | ? | | 228 | `MainButton` |
| 50 | ? | | 232 | `Slider` |
| 60 | Other text to display? (`UltEquip.mnu`) | | 233 | `StatusBar` |
| 61 | Text to display | | 400 | `ListBoxUp` |
| 70 | Hypertext to display | | 405 | `ListBoxDown` |
| 91 | ? |
| 100 | ? | `400`, `405`, and `45`, can all accept 4 values for `SUBMENUTYPE` in a
| 110 | ? | comma-separated list. These records combine to form a `TListBox` control, with a
| 120 | ? | number of visible slots that act as a viewport. There is a draggable vertical
| 200 | Drop-down button? | slider (the "thumb") to show where in the full list the viewport is, and up +
| 205 | Single list box item? | down buttons to move the position of the thumb by one, so it's feasible that
| 220 | Psyker power? | these values tell us about the available steps.
| 221 | Page? |
| 228 | Big buttons in `Main.mnu` | Here are the values in `Briefing.mnu`:
| 232 | ? |
| 233 | ? | ```
| 300 | Pop-up dialog box | #rem..........List Box Menu
| 400,0,0,{8, 16} | ? | MENUTYPE : 1 # List Box Menu
| 400,22,22,{2, 4, 5, 6, 7, 8, 9, 9, 10, 13, 16} | ? | SUBMENUTYPE: 400,22,22,13 # Scroll Up
| 400,30,-1,5 | ? | SUBMENUTYPE: 405,22,22,13 # Scroll Down
| 405,0,0,{8, 16} | ? | SUBMENUTYPE: 45, 14,15,13 # Thumb
| 405,22,22,{2, 4, 5, 6, 7, 8, 9, 10, 13, 16} | ? | ```
| 405,30,-1,5 | ? |
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 ## Positioning
@@ -195,6 +217,9 @@ successfully, for instance:
![](img/Options.mnu.png) ![](img/Options.mnu.png)
However, it's *not* sufficient to put all the items for `MainGame.mnu` in the
right place.
## Animation ## Animation
This seems to be done by choosing a different sprite to draw every N ticks. They 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. 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` ### `ACTIVE`
There are only 4 values seen across all menus: `0`, `1`, `1,0`, `102` and `1,1`. 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 objects in a space-efficient way via sets, which seem to be a kind of object
palette. 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 container structure
`.obj` files represent visual data. They contain a number of sprites, which are `.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) | | 0x0004 | x,y size (16 bits each) |
| 0x0008 | ? (blank in all cases so far) | 0x0008 | ? (blank in all cases so far)
| 0x000c | Size of remaining pixeldata | | 0x000c | Size of remaining pixeldata |
| 0x0010 | Padding? | | 0x0010 | Set in `WarHammer.ani` |
| 0x0014 | Padding? | | 0x0014 | ? (blank in all cases so far) |
The volume represented by a cell is a little odd. We see three faces of a fake 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 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 This lets me focus very narrowly on what happens when loading sprites, and
might give clues. 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)

21
doc/formats/sound.md Normal file
View File

@@ -0,0 +1,21 @@
# Sound files information
The Wav/ directory contains .wav files that are ADPCM-encoded. Old-fashioned,
not all WAV players support that any more :S.
Then there's `Sounds/wh40k.ds`. Fortunately, this one has already been worked
out for me: https://forum.xentax.com/viewtopic.php?f=10&t=2359
> Hmm, I can have a look, but I should tell you that some wavs are actually saved in the wav folder in the demo, and NOT stored in the ds file, even though they are listed in the header of the ds file.
>
> It's not a very difficult algorithm, but it's not straightforward. The "data" part of each wave file is stored one after the other, they stripped the headers of each wave file and stored the "WAVEfmt " headers in a separate table. THen there's the problem of the "fact" header in a wave file. They do not store this info in the table, so I had some default values. The extractor puts everything back together to a single .wav file.
>
> Just open a .wav file with a hex editor and you can see each field in the wav header. (i.e. "RIFF", "WAVEfmt ", "fact" and "data").
```
There's also `Data/Sounds.dat`, which ties constants to file names along with a
bit of metadata.
Currently I'm preprocessing the `Wav/` files into .mp3 to get some sort of sound
playing, but it would be nice to get ADPCM support natively and also to be able
to play the sounds in the .ds file.

19
go.mod
View File

@@ -1,12 +1,19 @@
module code.ur.gs/lupine/ordoor module code.ur.gs/lupine/ordoor
go 1.12 go 1.14
require ( require (
github.com/BurntSushi/toml v0.3.1 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
golang.org/x/exp v0.0.0-20200320212757-167ffe94c325 // indirect github.com/hajimehoshi/ebiten v1.11.1
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect github.com/jfreymuth/oggvorbis v1.0.1 // indirect
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0 // indirect github.com/kr/text v0.2.0 // indirect
golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae // 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
golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007 // indirect
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
) )

52
go.sum
View File

@@ -2,8 +2,11 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 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 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-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I=
@@ -12,11 +15,12 @@ github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc=
github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/hajimehoshi/bitmapfont v1.2.0/go.mod h1:h9QrPk6Ktb2neObTlAbma6Ini1xgMjbJ3w7ysmD7IOU= github.com/hajimehoshi/bitmapfont v1.2.0/go.mod h1:h9QrPk6Ktb2neObTlAbma6Ini1xgMjbJ3w7ysmD7IOU=
github.com/hajimehoshi/ebiten v1.10.2 h1:PiJBY4Q4udip675T+Zqvb3NKMp1eyLWBelp660ZMrkQ=
github.com/hajimehoshi/ebiten v1.10.2/go.mod h1:i9dIEUf5/MuPtbK1/wHR0PB7ZtqhjOxxg+U1xfxapcY=
github.com/hajimehoshi/ebiten v1.10.5 h1:hVb3GJP4IDqOETifRmPg4xmURRgbIJoB9gQk+Jqe8Uk=
github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5 h1:hke9UdXY1YPfqjXG1bCSZnoVnfVBw9SzvmlrRn3dL3w= github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5 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-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 h1:DH4ns3cPv39n3cs8MPcAlWqPeAwLCK8iNgqvg0QBWI8=
github.com/hajimehoshi/go-mp3 v0.2.1/go.mod h1:Rr+2P46iH6PwTPVgSsEwBkon0CK5DxCAeX/Rp65DCTE= 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= github.com/hajimehoshi/oto v0.3.4/go.mod h1:PgjqsBJff0efqL2nlMJidJgVJywLn6M4y8PI4TfeWfA=
@@ -25,6 +29,8 @@ github.com/hajimehoshi/oto v0.5.4/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2h
github.com/jakecoffman/cp v0.1.0/go.mod h1:a3xPx9N8RyFAACD644t2dj/nK4SuLg1v+jL61m2yVo4= github.com/jakecoffman/cp v0.1.0/go.mod h1:a3xPx9N8RyFAACD644t2dj/nK4SuLg1v+jL61m2yVo4=
github.com/jfreymuth/oggvorbis v1.0.0 h1:aOpiihGrFLXpsh2osOlEvTcg5/aluzGQeC7m3uYWOZ0= github.com/jfreymuth/oggvorbis v1.0.0 h1:aOpiihGrFLXpsh2osOlEvTcg5/aluzGQeC7m3uYWOZ0=
github.com/jfreymuth/oggvorbis v1.0.0/go.mod h1:abe6F9QRjuU9l+2jek3gj46lu40N4qlYxh2grqkLEDM= github.com/jfreymuth/oggvorbis v1.0.0/go.mod h1:abe6F9QRjuU9l+2jek3gj46lu40N4qlYxh2grqkLEDM=
github.com/jfreymuth/oggvorbis v1.0.1 h1:NT0eXBgE2WHzu6RT/6zcb2H10Kxj6Fm3PccT0LE6bqw=
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U= github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U=
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@@ -32,19 +38,26 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/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-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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85 h1:jqhIzSw5SQNkbu5hOGpgMHhkfXxrbsLJdkIRcX19gCY= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5 h1:FR+oGxGfbQu1d+jglI3rCkjAjUnhRSZcUxr+DqlDLNo=
golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20200319221330-857350248e3d h1:1kJNg12kVM6Xid7xoFkhq/YJVU4NMTv5b3hJCfQnwjc=
golang.org/x/exp v0.0.0-20200319221330-857350248e3d/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20200320212757-167ffe94c325 h1:iPGJw87eUJvke9YLYKX0jIwLHiIrY/kXcFSgOpjav28=
golang.org/x/exp v0.0.0-20200320212757-167ffe94c325/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -57,8 +70,9 @@ 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-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 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-20191025110607-73ccc5ba0426/go.mod h1:p895TfNkDgPEmEQrNiOtIl3j98d/tGU95djDj7NfyjQ=
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0 h1:nZASbxDuz7CO3227BWCCf0MC6ynyvKh6eMDoLcNXAk0=
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= 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= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@@ -74,18 +88,24 @@ golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d h1:62ap6LNOjDU6uGmKXHJbSfciMoV+FeI1sRXx/pLDL44= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae h1:3tcmuaB7wwSZtelmiv479UjUB+vviwABz7a133ZwOKQ= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY=
golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/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-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-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-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-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-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/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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/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 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-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 ( import (
"fmt" "fmt"
"image/color"
"io/ioutil" "io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"code.ur.gs/lupine/ordoor/internal/data" "github.com/hajimehoshi/ebiten"
)
const ( "code.ur.gs/lupine/ordoor/internal/config"
RootDir = "" // Used in the entryMap for entries pertaining to the root dir "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 entryMap map[string]map[string]string
// type AssetStore is responsible for lazily loading game data when it is // 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 // 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. // 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 // Cross-platform differences such as filename case sensitivity are also dealt
@@ -26,26 +28,47 @@ type entryMap map[string]map[string]string
// //
// We assume the directory is read-only. You can run Refresh() if you make a // We assume the directory is read-only. You can run Refresh() if you make a
// change. // change.
//
// To mix assets from different games, either construct a synthetic directory
// or instantiate two separate asset stores.
type AssetStore struct { type AssetStore struct {
RootDir string RootDir string
Palette color.Palette
// Case-insensitive file lookup. // Case-insensitive file lookup.
// {"":{"anim":"Anim", "obj":"Obj", ...}, "anim":{ "warhammer.ani":"WarHammer.ani" }, ...} // {"":{"anim":"Anim", "obj":"Obj", ...}, "anim":{ "warhammer.ani":"WarHammer.ani" }, ...}
entries entryMap entries entryMap
// These members are used to store things we've already loaded // These members are used to store things we've already loaded
maps map[string]*Map aniObj *Object
menus map[string]*Menu cursorObj *Object
objs map[string]*Object cursors map[CursorName]*Cursor
sets map[string]*Set fonts map[string]*Font
sounds map[string]*Sound generic *data.Generic
strings *data.I18n 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 // 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{ store := &AssetStore{
RootDir: dir, RootDir: engine.DataDir,
Palette: palette,
} }
// fill entryMap // fill entryMap
@@ -63,7 +86,7 @@ func (a *AssetStore) Refresh() error {
} }
newEntryMap := make(entryMap, len(rootEntries)) newEntryMap := make(entryMap, len(rootEntries))
newEntryMap[RootDir] = rootEntries newEntryMap[""] = rootEntries
for lower, natural := range rootEntries { for lower, natural := range rootEntries {
path := filepath.Join(a.RootDir, natural) path := filepath.Join(a.RootDir, natural)
@@ -83,7 +106,13 @@ func (a *AssetStore) Refresh() error {
} }
// Refresh // Refresh
a.aniObj = nil
a.cursorObj = nil
a.cursors = make(map[CursorName]*Cursor)
a.entries = newEntryMap 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.maps = make(map[string]*Map)
a.menus = make(map[string]*Menu) a.menus = make(map[string]*Menu)
a.objs = make(map[string]*Object) a.objs = make(map[string]*Object)
@@ -106,13 +135,13 @@ func (a *AssetStore) lookup(name, ext string, dirs ...string) (string, error) {
dir = canonical(dir) dir = canonical(dir)
if base, ok := a.entries[dir]; ok { if base, ok := a.entries[dir]; ok {
if file, ok := base[filename]; 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 filepath.Join(a.RootDir, actualDir, file), nil
} }
} }
} }
return "", os.ErrNotExist return "", fmt.Errorf("file %q does not exist", filename)
} }
func canonical(s string) string { 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,97 @@
package assetstore
import (
"fmt"
"image"
"log"
"code.ur.gs/lupine/ordoor/internal/fonts"
)
type Font struct {
Name string
mapping map[rune]*Sprite
}
func (a *AssetStore) Font(name string) (*Font, error) {
name = canonical(name)
// FIXME: these fonts don't exist. For now, point at one that does.
switch name {
case "imfnt13", "imfnt14":
name = "wh40k_12"
}
if font, ok := a.fonts[name]; ok {
return font, nil
}
log.Printf("Loading font %v", name)
filename, err := a.lookup(name, "fnt", "Fonts")
if err != nil {
return nil, err
}
raw, err := fonts.LoadFont(filename)
if err != nil {
return nil, err
}
objFile, err := a.lookup(raw.ObjectFile, "", "Fonts")
if err != nil {
return nil, err
}
obj, err := a.ObjectByPath(objFile)
if err != nil {
return nil, err
}
out := &Font{
Name: name,
mapping: make(map[rune]*Sprite, len(raw.Mapping)),
}
for r, offset := range raw.Mapping {
spr, err := obj.Sprite(offset)
if err != nil {
return nil, err
}
out.mapping[r] = spr
}
a.fonts[name] = out
return out, nil
}
// CalculateBounds tries to work out what sort of size the string will be when
// rendered
func (f *Font) CalculateBounds(text string) image.Rectangle {
width := 0
height := 0
for _, r := range text {
spr, ok := f.mapping[r]
if !ok {
continue // FIXME: we could add the space character or something?
}
width += spr.Rect.Dx()
if y := spr.Rect.Dy(); y > height {
height = y
}
}
return image.Rect(0, 0, width, height)
}
func (f *Font) Glyph(r rune) (*Sprite, error) {
glyph, ok := f.mapping[r]
if !ok {
return nil, fmt.Errorf("Font %v does not specify rune %v", f.Name, r)
}
return glyph, nil
}

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

View File

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

View File

@@ -1,18 +1,21 @@
package config package config
import ( import (
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
type Ordoor struct { type Engine struct {
DataDir string `toml:"data_dir"` DataDir string `toml:"data_dir"`
VideoPlayer []string `toml:"video_player"` Palette string `toml:"palette"`
} }
// Things set // Things set in the options hash
// TODO: load defaults from Data/GenericData.dat if they're not set
type Options struct { type Options struct {
PlayMovies bool `toml:"play_movies"` PlayMovies bool `toml:"play_movies"`
Animations bool `toml:"animations"` Animations bool `toml:"animations"`
@@ -36,11 +39,40 @@ type Options struct {
type Config struct { type Config struct {
filename string `toml:"-"` filename string `toml:"-"`
Ordoor `toml:"ordoor"` VideoPlayer []string `toml:"video_player"`
Options `toml:"options"` 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 var out Config
_, err := toml.DecodeFile(filename, &out) _, err := toml.DecodeFile(filename, &out)
@@ -50,7 +82,21 @@ func Load(filename string) (*Config, error) {
out.filename = filename 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 { func (c *Config) Save() error {
@@ -63,7 +109,45 @@ func (c *Config) Save() error {
return toml.NewEncoder(f).Encode(c) return toml.NewEncoder(f).Encode(c)
} }
// TODO: case-insensitive lookup func (c *Config) ResetDefaults() error {
func (c *Config) DataFile(path string) string { if c.Defaults == nil {
return filepath.Join(c.DataDir, path) return errors.New("Defaults not available")
}
c.Options = *c.Defaults
return c.Save()
}
func (o *Options) ResolutionIndex() int {
if o.XRes == 640 && o.YRes == 480 {
return 1
}
if o.XRes == 800 && o.YRes == 600 {
return 2
}
if o.XRes == 1024 && o.YRes == 768 {
return 3
}
return 4 // Magic value
}
func (o *Options) SetResolutionIndex(value int) {
switch value {
case 1:
o.XRes = 640
o.YRes = 480
case 2:
o.XRes = 800
o.YRes = 600
case 3:
o.XRes = 1024
o.YRes = 768
}
// If the value isn't recognised, silently ignore the request to avoid
// overwriting options the resolution slider doesn't know about
} }

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

@@ -13,7 +13,7 @@ import (
// file that maps string IDs to messages // file that maps string IDs to messages
type I18n struct { type I18n struct {
Name string Name string
mapping map[int]string mapping map[int]Entry
} }
// FIXME: this should be put into the config file maybe, or detected from a list // FIXME: this should be put into the config file maybe, or detected from a list
@@ -22,6 +22,15 @@ const (
I18nFile = "USEng.dta" I18nFile = "USEng.dta"
) )
// I18n entries may have 1 or 2 items. If two, the first is a menu item and the
// second is a help string for that menu item.
//
// The text or help may contain some magic strings, as mentioned in USEng.data
type Entry struct {
Text string
Help string
}
func LoadI18n(filename string) (*I18n, error) { func LoadI18n(filename string) (*I18n, error) {
f, err := os.Open(filename) f, err := os.Open(filename)
if err != nil { if err != nil {
@@ -32,7 +41,7 @@ func LoadI18n(filename string) (*I18n, error) {
out := &I18n{ out := &I18n{
Name: filepath.Base(filename), Name: filepath.Base(filename),
mapping: make(map[int]string), mapping: make(map[int]Entry),
} }
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
@@ -63,7 +72,12 @@ func LoadI18n(filename string) (*I18n, error) {
// TODO: Replace certain escape characters with their literals? // TODO: Replace certain escape characters with their literals?
out.mapping[num] = string(val) if entry, ok := out.mapping[num]; !ok { // first time we've seen this
out.mapping[num] = Entry{Text: string(val)}
} else { // Second time, the item is help text
entry.Help = string(val)
out.mapping[num] = entry
}
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
@@ -78,8 +92,14 @@ func (n *I18n) Len() int {
} }
// Puts the internationalized string into `out` if `in` matches a known ID // Puts the internationalized string into `out` if `in` matches a known ID
func (n *I18n) Replace(in int, out *string) { func (n *I18n) ReplaceText(in int, out *string) {
if str, ok := n.mapping[in]; ok { if str, ok := n.mapping[in]; ok {
*out = str *out = str.Text
}
}
func (n *I18n) ReplaceHelp(in int, out *string) {
if str, ok := n.mapping[in]; ok {
*out = str.Help
} }
} }

View File

@@ -4,8 +4,10 @@ import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"image" "image"
"image/color"
"io" "io"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -19,13 +21,26 @@ type SpriteHeader struct {
Width uint16 Width uint16
Height uint16 Height uint16
Padding1 uint32 // I don't think this is used. Could be wrong. Padding1 uint32 // I don't think this is used. Could be wrong.
PixelSize uint32 // Size of PixelData, excluding this sprite header PixelSize uint32
Padding2 uint64 // I don't think this is used either. Could be wrong. 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 { func (s SpriteHeader) Check(expectedSize uint32) error {
if s.Padding1 != 0 || s.Padding2 != 0 { 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 // Remove 24 bytes from passed-in size to account for the header
@@ -42,12 +57,12 @@ type Sprite struct {
Data []byte Data []byte
} }
func (s *Sprite) ToImage() *image.Paletted { func (s *Sprite) ToImage(palette color.Palette) *image.Paletted {
return &image.Paletted{ return &image.Paletted{
Pix: s.Data, Pix: s.Data,
Stride: int(s.Width), Stride: int(s.Width),
Rect: image.Rect(0, 0, int(s.Width), int(s.Height)), 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
}

105
internal/flow/options.go Normal file
View File

@@ -0,0 +1,105 @@
package flow
import (
"log"
)
func (f *Flow) linkOptions() {
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.configureSlider(options, "2.26", h9Slider) // Unit speed slider
f.configureSlider(options, "2.27", h9Slider) // Animation speed slider
f.linkKeyboard()
}
// FIXME: exiting is a bit OTT. Perhaps display "save failed"?
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() {
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) configIntoOptions() error {
var err error
cfg := &f.config.Options
optionsUI := f.drivers[options]
try(optionsUI.SetValueBool("2.1", cfg.PlayMovies), &err)
try(optionsUI.SetValueBool("2.1", cfg.Animations), &err)
try(optionsUI.SetValueBool("2.3", cfg.PlayMusic), &err)
try(optionsUI.SetValueBool("2.4", cfg.CombatVoices), &err)
try(optionsUI.SetValueBool("2.5", cfg.ShowGrid), &err)
try(optionsUI.SetValueBool("2.6", cfg.ShowPaths), &err)
try(optionsUI.SetValueBool("2.7", cfg.PointSaving), &err)
try(optionsUI.SetValueInt("2.9", cfg.ResolutionIndex()), &err)
try(optionsUI.SetValueInt("2.10", cfg.MusicVolume), &err)
try(optionsUI.SetValueInt("2.11", cfg.SFXVolume), &err)
try(optionsUI.SetValueBool("2.25", cfg.AutoCutLevel), &err)
try(optionsUI.SetValueInt("2.26", cfg.UnitSpeed), &err)
try(optionsUI.SetValueInt("2.27", cfg.AnimSpeed), &err)
return err
}
func (f *Flow) optionsIntoConfig() error {
var resIdx int // needs handling manually
var err error
cfg := &f.config.Options
optionsUI := f.drivers[options]
try(optionsUI.ValueBool("2.1", &cfg.PlayMovies), &err)
try(optionsUI.ValueBool("2.2", &cfg.Animations), &err)
try(optionsUI.ValueBool("2.3", &cfg.PlayMusic), &err)
try(optionsUI.ValueBool("2.4", &cfg.CombatVoices), &err)
try(optionsUI.ValueBool("2.5", &cfg.ShowGrid), &err)
try(optionsUI.ValueBool("2.6", &cfg.ShowPaths), &err)
try(optionsUI.ValueBool("2.7", &cfg.PointSaving), &err)
try(optionsUI.ValueInt("2.9", &resIdx), &err)
try(optionsUI.ValueInt("2.10", &cfg.MusicVolume), &err)
try(optionsUI.ValueInt("2.11", &cfg.SFXVolume), &err)
try(optionsUI.ValueBool("2.25", &cfg.AutoCutLevel), &err)
try(optionsUI.ValueInt("2.26", &cfg.UnitSpeed), &err)
try(optionsUI.ValueInt("2.27", &cfg.AnimSpeed), &err)
if err != nil {
return err
}
cfg.SetResolutionIndex(resIdx)
if err := f.config.Save(); err != nil {
return err
}
return nil
}
func try(result error, into *error) {
if *into == nil {
*into = result
}
}

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
}

View File

@@ -10,17 +10,28 @@ import (
"code.ur.gs/lupine/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
type Range struct {
Min Point
Max Point
}
type Point struct {
Rune rune
Sprite int
}
type Font struct { type Font struct {
Name string Name string
// Contains the sprite data for the font. FIXME: load this? // Contains the sprite data for the font
ObjectFile string ObjectFile string
// Maps ASCII bytes to a sprite offset in the ObjectFile // Maps ASCII bytes to a sprite offset in the ObjectFile
mapping map[int]int Ranges []Range
Mapping map[rune]int
} }
func (f *Font) Entries() int { func (f *Font) Entries() int {
return len(f.mapping) return len(f.Mapping)
} }
// Returns the offsets required to display a given string, returning an error if // Returns the offsets required to display a given string, returning an error if
@@ -30,7 +41,7 @@ func (f *Font) Indices(s string) ([]int, error) {
for i, b := range []byte(s) { for i, b := range []byte(s) {
offset, ok := f.mapping[int(b)] offset, ok := f.Mapping[rune(b)]
if !ok { if !ok {
return nil, fmt.Errorf("Unknown codepoint %v at offset %v in string %s", b, i, s) return nil, fmt.Errorf("Unknown codepoint %v at offset %v in string %s", b, i, s)
} }
@@ -58,7 +69,7 @@ func LoadFont(filename string) (*Font, error) {
out := &Font{ out := &Font{
Name: filepath.Base(filename), Name: filepath.Base(filename),
ObjectFile: objFile, ObjectFile: objFile,
mapping: make(map[int]int), Mapping: make(map[rune]int),
} }
for { for {
@@ -82,15 +93,32 @@ func LoadFont(filename string) (*Font, error) {
cpEnd, _ := strconv.Atoi(fields[2]) cpEnd, _ := strconv.Atoi(fields[2])
idxStart, _ := strconv.Atoi(fields[3]) idxStart, _ := strconv.Atoi(fields[3])
idxEnd, _ := strconv.Atoi(fields[4]) idxEnd, _ := strconv.Atoi(fields[4])
size := idxEnd - idxStart cpSize := cpEnd - cpStart
idxSize := idxEnd - idxStart
// FIXME: I'd love this to be an error but several .fnt files do it // FIXME: I'd love this to be an error but several .fnt files do it
if cpEnd-cpStart != size { // Take the smallest range
fmt.Printf("WARNING: %v has mismatched codepoints and indices: %q\n", filename, str) if cpSize != idxSize {
fmt.Printf("WARNING: %v has mismatched codepoints (sz=%v) and indices (sz=%v): %q\n", filename, cpSize, idxSize, str)
if cpSize < idxSize {
idxEnd = idxStart + cpSize
idxSize = cpSize
} else {
cpEnd = cpStart + idxSize
cpSize = idxSize
}
} }
for offset := 0; offset < size; offset++ { r := Range{
out.mapping[cpStart+offset] = idxStart + offset Min: Point{Rune: rune(cpStart), Sprite: idxStart},
Max: Point{Rune: rune(cpEnd), Sprite: idxEnd},
}
out.Ranges = append(out.Ranges, r)
for offset := 0; offset <= cpSize; offset++ {
out.Mapping[rune(cpStart+offset)] = idxStart + offset
} }
case "v": // A single codepoint, 4 fields case "v": // A single codepoint, 4 fields
if len(fields) < 3 { if len(fields) < 3 {
@@ -99,8 +127,11 @@ func LoadFont(filename string) (*Font, error) {
cp, _ := strconv.Atoi(fields[1]) cp, _ := strconv.Atoi(fields[1])
idx, _ := strconv.Atoi(fields[2]) idx, _ := strconv.Atoi(fields[2])
pt := Point{Rune: rune(cp), Sprite: idx}
out.mapping[cp] = idx out.Ranges = append(out.Ranges, Range{Min: pt, Max: pt})
out.Mapping[rune(cp)] = idx
default: default:
return nil, parseErr return nil, parseErr
} }

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" "compress/gzip"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"image"
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
@@ -14,8 +15,8 @@ import (
) )
var ( var (
expectedMagic = []byte("\x08\x00WHMAP\x00") expectedMagic = []byte("\x15\x00AMB_MAP\x00")
expectedSetNameOffset = uint32(0x34) expectedSetNameOffset = uint32(0x10)
notImplemented = fmt.Errorf("Not implemented") notImplemented = fmt.Errorf("Not implemented")
) )
@@ -24,41 +25,53 @@ const (
MaxLength = 100 // Y coordinate MaxLength = 100 // Y coordinate
MaxWidth = 130 // X coordinate MaxWidth = 130 // X coordinate
CellSize = 16 // seems to be CellSize = 13 // seems to be
cellDataOffset = 0x110 // tentatively cellDataOffset = 0xc0
cellCount = MaxHeight * MaxLength * MaxWidth cellCount = MaxHeight * MaxLength * MaxWidth
) )
type Header struct { type Header struct {
IsCampaignMap uint32 // Tentatively: 0 = no, 1 = yes Magic [10]byte // "\x15\x00AMB_MAP\x00"
MinWidth uint32 SetName [8]byte // Links to a filename in `/Sets/*.set`
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`
// Need to investigate the rest of the header too // Need to investigate the rest of the header too
IsCampaignMap byte
} }
func (h Header) Width() int { type TrailerHeader struct {
return int(h.MaxWidth - h.MinWidth) 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 { type TrailerTrailer struct {
return int(h.MaxLength - h.MinLength) Title [255]byte
Briefing [2048]byte
Unknown1 [85]uint8 // Maybe? each contains either 0 or 1? Hard to say
} }
func (h Header) Height() int { type Character struct {
return MaxHeight Unknown1 uint32
} }
type Characters []Character
// TODO. These are triggers/reactors/etc.
type Thingy struct {}
type Thingies []Thingy
func (h Header) MapSetName() string { func (h Header) MapSetName() string {
idx := bytes.IndexByte(h.SetName[:], 0) idx := bytes.IndexByte(h.SetName[:], 0)
if idx < 0 { if idx < 0 {
@@ -79,7 +92,7 @@ type ObjRef struct {
// The index into a set palette to retrieve the object // The index into a set palette to retrieve the object
func (o ObjRef) Index() int { func (o ObjRef) Index() int {
return int(o.AreaByte) return int(o.AreaByte & 0x7f)
} }
func (o ObjRef) Sprite() int { 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. // The top bit seems to say whether we should draw or not.
func (o ObjRef) IsActive() bool { func (o ObjRef) IsActive() bool {
return (o.SpriteAndFlagByte & 0x80) == 0x80 return (o.SpriteAndFlagByte & 0x80) == 0x80
} } // PARIS is 78 x 60 x 7
// 4E 3C 7
/*
type Cell struct { type Cell struct {
DoorAndCanisterRelated byte DoorAndCanisterRelated byte
DoorLockAndReactorRelated byte // DoorLockAndReactorRelated byte
Unknown2 byte // Unknown2 byte
Surface ObjRef Surface ObjRef
Left ObjRef Left ObjRef
Right ObjRef Right ObjRef
@@ -104,43 +118,60 @@ type Cell struct {
Unknown12 byte Unknown12 byte
Unknown13 byte Unknown13 byte
Unknown14 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 { func (c *Cell) At(n int) byte {
switch n { switch n {
case 0: case 0:
return c.DoorAndCanisterRelated return c.Unknown1
case 1: case 1:
return c.DoorLockAndReactorRelated
case 2:
return c.Unknown2
case 3:
return c.Surface.AreaByte return c.Surface.AreaByte
case 4: case 2:
return c.Surface.SpriteAndFlagByte return c.Surface.SpriteAndFlagByte
case 5: case 3:
return c.Left.AreaByte return c.Left.AreaByte
case 6: case 4:
return c.Left.SpriteAndFlagByte return c.Left.SpriteAndFlagByte
case 7: case 5:
return c.Right.AreaByte return c.Right.AreaByte
case 8: case 6:
return c.Right.SpriteAndFlagByte return c.Right.SpriteAndFlagByte
case 9: case 7:
return c.Center.AreaByte return c.Center.AreaByte
case 10: case 8:
return c.Center.SpriteAndFlagByte return c.Center.SpriteAndFlagByte
case 9:
return c.Unknown2[0]
case 10:
return c.Unknown2[1]
case 11: case 11:
return c.Unknown11 return c.Unknown2[2]
case 12: case 12:
return c.Unknown12 return c.Unknown2[3]
case 13:
return c.Unknown13
case 14:
return c.Unknown14
case 15:
return c.SquadRelated
} }
return 0 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 // Cells is always a fixed size; use At to get a cell according to x,y,z
type Cells []Cell 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 { 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 { func (h Header) Check() []error {
var out []error var out []error
if h.IsCampaignMap > 1 { // if h.IsCampaignMap > 1 {
out = append(out, fmt.Errorf("Expected 0 or 1 for IsCampaignMap, got %v", h.IsCampaignMap)) // out = append(out, fmt.Errorf("Expected 0 or 1 for IsCampaignMap, got %v", h.IsCampaignMap))
} // }
if bytes.Compare(expectedMagic, h.Magic[:]) != 0 { if bytes.Compare(expectedMagic, h.Magic[:]) != 0 {
out = append(out, fmt.Errorf("Unexpected magic value: %v", h.Magic)) out = append(out, fmt.Errorf("Unexpected magic value: %v", h.Magic))
@@ -169,10 +208,26 @@ func (h Header) Check() []error {
type GameMap struct { type GameMap struct {
Header Header
Cells 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 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, // A game map contains a .txt and a .map. If they're in the same directory,
// just pass the directory + basename to load both // just pass the directory + basename to load both
func LoadGameMap(prefix string) (*GameMap, error) { 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) 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 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,6 +2,8 @@ package menus
import ( import (
"fmt" "fmt"
"image"
"image/color"
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -10,38 +12,68 @@ import (
"code.ur.gs/lupine/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
// MenuType tells us what sort of Group we have
type MenuType int type MenuType int
// SubMenuType tells us what sort of Record we have
type SubMenuType int
const ( const (
TypeStatic MenuType = 0 TypeStatic MenuType = 0
TypeMenu MenuType = 1 TypeMenu MenuType = 1
TypeButton MenuType = 3 TypeDragMenu MenuType = 2 // Only seen in Configure_Vehicle_{Chaos,Ultra}
TypeInvokeButton MenuType = 50 TypeRadioMenu MenuType = 3 // ???
TypeOverlay MenuType = 61 TypeMainBackground MenuType = 45 // ???
TypeHypertext MenuType = 70 TypeDialogue MenuType = 300
TypeCheckbox MenuType = 91
TypeAnimationSample MenuType = 220 SubTypeSimpleButton SubMenuType = 3
TypeMainButton MenuType = 228 SubTypeDoorHotspot1 SubMenuType = 30 // Like a button I guess? "FONTTYPE is animation speed"
TypeSlider MenuType = 232 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
) )
type Record struct { // FIXME: certain elements - especially overlays - don't have a DESC specified
Parent *Record // in the .mnu file, but display text specified with a number in i18n. The only
Children []*Record // conclusion I can draw is that they're hardcoded in the binary and set from
// outside. So, do that here.
var DescOverrides = map[string]int{
"main:2.6": 50992,
"newgame:2.5": 50993,
"keyboard:3.3": 50995,
"levelply:2.6": 50996,
}
Id int // FIXME: Same idea with text overrides, only these aren't mentioned in the .dta
Type MenuType // file at all!
DrawType int var TextOverrides = map[string]string{
FontType int "main:2.7": "0.1-ordoor",
Active bool }
SpriteId []int
Share int
X int
Y int
Desc string
// FIXME: turn these into first-class data var TypeOverrides = map[string]SubMenuType{
properties map[string]string // 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 { type Menu struct {
@@ -50,18 +82,71 @@ type Menu struct {
ObjectFiles []string ObjectFiles []string
FontNames []string FontNames []string
// FIXME: turn these into first-class data // These are properties set in the menu header. We don't know what they're
Properties map[string]string // all for.
BackgroundColor color.Color
HypertextColor color.Color
FontType int
// The actual menu records. There are multiple top-level items. Submenus are // The actual menu records. There are multiple top-level items. Submenus are
// only ever nested one deep. // 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
name := filepath.Base(filename) // 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.TrimSuffix(name, filepath.Ext(name))
name = strings.ToLower(name)
// FIXME: this needs turning into a real parser sometime
scanner, err := asciiscan.New(filename) scanner, err := asciiscan.New(filename)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -69,66 +154,171 @@ func LoadMenu(filename string) (*Menu, error) {
defer scanner.Close() defer scanner.Close()
var str string
var record *Record
section := 0
isProp := false
out := &Menu{ out := &Menu{
Name: name, Name: name,
Properties: map[string]string{},
} }
for { if err := loadObjects(out, scanner); err != nil {
str, err = scanner.ConsumeString() return nil, err
if err != nil { }
return nil, err
}
// Whether the lines are properties or not alternate with each section, if err := loadProperties(out, scanner, palette); err != nil {
// except the records use `*` as a separator return nil, err
if section < 3 && isProp != asciiscan.IsProperty(str) { }
section += 1
isProp = !isProp
}
if str == "~" { if err := loadFonts(out, scanner); err != nil {
break return nil, err
} }
switch section { if err := loadRecords(filepath.Dir(filename), out, scanner); err != nil {
case 0: // List of object files return nil, err
out.ObjectFiles = append(out.ObjectFiles, str)
case 1: // List of properties
k, v := asciiscan.ConsumeProperty(str)
out.Properties[k] = v
case 2: // list of fonts
// FIXME: do we need to do something cleverer here?
if str == "NULL" {
continue
}
out.FontNames = append(out.FontNames, str)
case 3: // Menu records
if str == "*" { // NEXT RECORD
out.Records = append(out.Records, record.Toplevel())
continue
}
k, v := asciiscan.ConsumeProperty(str)
switch k {
case "MENUID":
record = newRecord(nil)
case "SUBMENUID":
record = newRecord(record.Toplevel())
}
setProperty(record, k, v)
}
} }
return out, nil 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) fis, err := ioutil.ReadDir(dir)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -146,7 +336,7 @@ func LoadMenus(dir string) (map[string]*Menu, error) {
continue continue
} }
built, err := LoadMenu(filepath.Join(dir, relname)) built, err := LoadMenu(filepath.Join(dir, relname), palette)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %v", filepath.Join(dir, relname), err) return nil, fmt.Errorf("%s: %v", filepath.Join(dir, relname), err)
} }
@@ -157,79 +347,143 @@ func LoadMenus(dir string) (map[string]*Menu, error) {
return out, nil return out, nil
} }
func newRecord(parent *Record) *Record { func listOfInts(s string) []int {
out := &Record{ vSplit := strings.Split(s, ",")
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)
vSplitInt := make([]int, len(vSplit)) vSplitInt := make([]int, len(vSplit))
for i, subV := range vSplit { for i, subV := range vSplit {
vSplitInt[i], _ = strconv.Atoi(subV) vSplitInt[i], _ = strconv.Atoi(subV)
} }
switch k { return vSplitInt
case "MENUID", "SUBMENUID": }
r.Id = vInt
case "MENUTYPE", "SUBMENUTYPE": func newGroup(menu *Menu, idStr string) *Group {
r.Type = MenuType(vInt) out := &Group{Menu: menu}
case "ACTIVE":
r.Active = (vInt != 0) // ObjectIdx can be specified in the MENUID. Only seen for .mni files
case "SPRITEID": ints := listOfInts(idStr)
r.SpriteId = vSplitInt out.ID = ints[0]
case "X-CORD": if len(ints) > 1 {
r.X = vInt out.ObjectIdx = ints[1]
case "Y-CORD":
r.Y = vInt
case "DESC":
r.Desc = v
case "FONTTYPE":
r.FontType = vInt
case "DRAW TYPE":
r.DrawType = vInt
case "SHARE":
r.Share = vInt
default:
r.properties[k] = v
} }
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 { type Replacer interface {
Replace(int, *string) ReplaceText(int, *string)
ReplaceHelp(int, *string)
} }
func (r *Record) Internationalize(replacer Replacer) { func (r *Record) Internationalize(replacer Replacer) {
id, err := strconv.Atoi(r.Desc) if override, ok := TextOverrides[r.Locator]; ok {
if err == nil { r.Text = override
replacer.Replace(id, &r.Desc) return
} }
for _, child := range r.Children { if override, ok := DescOverrides[r.Locator]; ok {
child.Internationalize(replacer) r.Desc = strconv.Itoa(override)
}
id, err := strconv.Atoi(r.Desc)
if err == nil {
replacer.ReplaceText(id, &r.Text)
replacer.ReplaceHelp(id, &r.Help)
} else {
r.Text = r.Desc // Sometimes it's a string like "EQUIPMENT"
} }
} }
func (m *Menu) Internationalize(replacer Replacer) { func (m *Menu) Internationalize(replacer Replacer) {
for _, record := range m.Records { for _, group := range m.Groups {
record.Internationalize(replacer) for _, record := range group.Records {
record.Internationalize(replacer)
}
} }
} }
func (g *Group) Props() *Properties {
return &g.Properties
}
func (r *Record) Props() *Properties {
return &r.Properties
}
func (p *Properties) BaseSpriteID() int {
base := p.Share
// SpriteId takes precedence if present
if len(p.SpriteId) > 0 && p.SpriteId[0] >= 0 {
base = p.SpriteId[0]
}
return base
}

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,221 +0,0 @@
package ordoor
import (
"fmt"
"log"
"code.ur.gs/lupine/ordoor/internal/ui"
)
func try(result error, into *error) {
if *into == nil {
*into = result
}
}
// These are UI interfaces covering the game entrypoint
func (o *Ordoor) ifaceMain() (*ui.Interface, error) {
// Start in the "main" menu
main, err := o.buildInterface("main")
if err != nil {
return nil, err
}
options, err := o.ifaceOptions(main)
if err != nil {
return nil, err
}
// TODO: clicking these buttons should load other interfaces
try(wireupClick(main, func() {}, 2, 1), &err) // New game
try(wireupClick(main, func() {}, 2, 2), &err) // Load game
try(disableWidget(main, 2, 3), &err) // Multiplayer - disable for now
try(wireupClick(main, func() { o.iface = options }, 2, 4), &err) // Options
try(wireupClick(main, func() { o.nextState = StateExit }, 2, 5), &err) // Quit
return main, err
}
// Options needs to know how to go back to main
func (o *Ordoor) ifaceOptions(main *ui.Interface) (*ui.Interface, error) {
options, err := o.buildInterface("options")
if err != nil {
return nil, err
}
if err := o.configIntoOptions(options); err != nil {
return nil, err
}
// TODO: load current options state into UI
try(wireupClick(options, func() {}, 2, 8), &err) // Keyboard settings button
// Resolution slider is 2,9
// Music volume slider is 2,10
// Sound FX volume slider is 2,11
// Accept button
try(wireupClick(
options, func() {
if err := o.optionsIntoConfig(options); err != nil {
// FIXME: exiting is a bit OTT. Perhaps display "save failed"?
log.Printf("Saving options to config failed: %v", err)
o.nextState = StateExit
} else {
o.iface = main
}
},
2, 12,
), &err)
// 13...23 are "hypertext"
// Cancel button
try(
wireupClick(
options,
func() {
// FIXME: again, exiting is OTT. We're just resetting the state of
// the interface to the values in config.
if err := o.configIntoOptions(options); err != nil {
log.Printf("Saving options to config failed: %v", err)
o.nextState = StateExit
} else {
o.iface = main
}
},
2, 24,
), &err)
// Unit speed slider is 2,26
// Looping effect speed slider is 2,27
// Sample of unit speed animation is 2,28
// Sample of effect speed animation is 2,29
// 30...35 are "hypertext"
return options, err
}
func (o *Ordoor) configIntoOptions(options *ui.Interface) error {
cfg := &o.config.Options
var err error
try(setWidgetValueBool(options, cfg.PlayMovies, 2, 1), &err)
try(setWidgetValueBool(options, cfg.Animations, 2, 2), &err)
try(setWidgetValueBool(options, cfg.PlayMusic, 2, 3), &err)
try(setWidgetValueBool(options, cfg.CombatVoices, 2, 4), &err)
try(setWidgetValueBool(options, cfg.ShowGrid, 2, 5), &err)
try(setWidgetValueBool(options, cfg.ShowPaths, 2, 6), &err)
try(setWidgetValueBool(options, cfg.PointSaving, 2, 7), &err)
try(setWidgetValueBool(options, cfg.AutoCutLevel, 2, 25), &err)
return err
}
func (o *Ordoor) optionsIntoConfig(options *ui.Interface) error {
cfg := &o.config.Options
var err error
try(getWidgetValueBool(options, &cfg.PlayMovies, 2, 1), &err)
try(getWidgetValueBool(options, &cfg.Animations, 2, 2), &err)
try(getWidgetValueBool(options, &cfg.PlayMusic, 2, 3), &err)
try(getWidgetValueBool(options, &cfg.CombatVoices, 2, 4), &err)
try(getWidgetValueBool(options, &cfg.ShowGrid, 2, 5), &err)
try(getWidgetValueBool(options, &cfg.ShowPaths, 2, 6), &err)
try(getWidgetValueBool(options, &cfg.PointSaving, 2, 7), &err)
try(getWidgetValueBool(options, &cfg.AutoCutLevel, 2, 25), &err)
if err != nil {
return err
}
return o.config.Save()
}
func (o *Ordoor) buildInterface(name string) (*ui.Interface, error) {
menu, err := o.assets.Menu(name)
if err != nil {
return nil, err
}
iface, err := ui.NewInterface(menu)
if err != nil {
return nil, err
}
return iface, nil
}
func findWidget(iface *ui.Interface, spec ...int) (*ui.Widget, error) {
widget, err := iface.Widget(spec...)
if err != nil {
return nil, fmt.Errorf("Couldn't find widget %v:%+v", iface.Name, spec)
}
return widget, nil
}
func getWidgetValue(iface *ui.Interface, spec ...int) (string, error) {
widget, err := findWidget(iface, spec...)
if err != nil {
return "", err
}
return widget.Value, nil
}
func setWidgetValue(iface *ui.Interface, value string, spec ...int) error {
widget, err := findWidget(iface, spec...)
if err != nil {
return err
}
widget.Value = value
return nil
}
func getWidgetValueBool(iface *ui.Interface, into *bool, spec ...int) error {
vStr, err := getWidgetValue(iface, spec...)
if err != nil {
return err
}
*into = vStr == "1"
return nil
}
func setWidgetValueBool(iface *ui.Interface, value bool, spec ...int) error {
vStr := "0"
if value {
vStr = "1"
}
return setWidgetValue(iface, vStr, spec...)
}
func wireupClick(iface *ui.Interface, f func(), spec ...int) error {
widget, err := findWidget(iface, spec...)
if err != nil {
return err
}
if widget.OnMouseClick != nil {
return fmt.Errorf("Widget %#+v already has an OnMouseClick handler", widget)
}
widget.OnMouseClick = f
return nil
}
func disableWidget(iface *ui.Interface, spec ...int) error {
widget, err := findWidget(iface, spec...)
if err != nil {
return err
}
widget.Disable()
return nil
}

View File

@@ -5,66 +5,81 @@
package ordoor package ordoor
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"time"
"github.com/hajimehoshi/ebiten" "github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/audio" "github.com/hajimehoshi/ebiten/audio"
"code.ur.gs/lupine/ordoor/internal/assetstore" "code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config" "code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/flow"
"code.ur.gs/lupine/ordoor/internal/ship"
"code.ur.gs/lupine/ordoor/internal/ui" "code.ur.gs/lupine/ordoor/internal/ui"
) )
type gameState int type gameState int
const (
StateInitial gameState = 0
StateInterface gameState = 1
StateExit gameState = 666
)
var (
errExit = errors.New("User-requested exit action")
)
type Ordoor struct { type Ordoor struct {
assets *assetstore.AssetStore assets *assetstore.AssetStore
config *config.Config config *config.Config
music *audio.Player music *audio.Player
win *ui.Window win *ui.Window
state gameState
nextState gameState
// Relevant to interface state // Relevant to interface state
iface *ui.Interface 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) error { func Run(configFile string, overrideX, overrideY int) error {
cfg, err := config.Load(configFile) cfg, err := config.Load(configFile, "ordoor")
if err != nil { if err != nil {
return fmt.Errorf("Couldn't load config file: %v", err) 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 { if err != nil {
return fmt.Errorf("Failed to initialize asset store: %v", err) 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 { if _, err := audio.NewContext(48000); err != nil {
return fmt.Errorf("Failed to set up audio context: %v", err) return fmt.Errorf("Failed to set up audio context: %v", err)
} }
ordoor := &Ordoor{ ordoor := &Ordoor{
assets: assets, assets: assets,
config: cfg, config: cfg,
state: StateInitial, ship: ship.New(),
nextState: StateInterface,
} }
win, err := ui.NewWindow(ordoor, "Ordoor", cfg.Options.XRes, cfg.Options.YRes) x, y := cfg.Options.XRes, cfg.Options.YRes
if overrideX > 0 {
x = overrideX
}
if overrideY > 0 {
y = overrideY
}
win, err := ui.NewWindow(ordoor, "Ordoor", x, y)
if err != nil { if err != nil {
return fmt.Errorf("Failed to create window: %v", err) return fmt.Errorf("Failed to create window: %v", err)
} }
@@ -72,20 +87,25 @@ func Run(configFile string) error {
ordoor.win = win ordoor.win = win
if err := ordoor.Run(); err != nil { if err := ordoor.Run(); err != nil {
return fmt.Errorf("Run returned %v", err) return fmt.Errorf("Run finished with error: %v", err)
} }
return nil return nil
} }
func (o *Ordoor) Run() error { func (o *Ordoor) Run() error {
// FIXME: these should be displayed *after*, not *before*, the copyright
if o.config.Options.PlayMovies { if o.config.Options.PlayMovies {
o.PlayUnskippableVideo("LOGOS") o.PlaySkippableVideo("LOGOS")
o.PlaySkippableVideo("movie1") 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() err := o.win.Run()
if err == errExit { if err == flow.ErrExit {
log.Printf("Exit requested") log.Printf("Exit requested")
return nil return nil
} }
@@ -95,6 +115,8 @@ func (o *Ordoor) Run() error {
// Only one music track can play at a time. This is handled at the toplevel. // Only one music track can play at a time. This is handled at the toplevel.
// FIXME: should take references from Sounds.dat // 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 { func (o *Ordoor) PlayMusic(name string) error {
if o.music != nil { if o.music != nil {
if err := o.music.Close(); err != nil { if err := o.music.Close(); err != nil {
@@ -112,57 +134,70 @@ func (o *Ordoor) PlayMusic(name string) error {
return fmt.Errorf("Failed to generate music player for %v: %v", name, err) return fmt.Errorf("Failed to generate music player for %v: %v", name, err)
} }
o.music = player o.music = player
player.Play()
if o.config.Options.PlayMusic {
player.Play()
}
return nil return nil
} }
func (o *Ordoor) setupInterface() error { func (o *Ordoor) setupFlow() error {
o.PlayMusic("music_interface") o.PlayMusic("music_interface")
initial, err := o.ifaceMain()
flow, err := flow.New(o.assets, o.config, o.ship)
if err != nil { if err != nil {
return err return err
} }
o.iface = initial o.flow = flow
return nil return nil
} }
func (o *Ordoor) Update(screenX, screenY int) error { func (o *Ordoor) Update(screenX, screenY int) error {
// Perform state transitions if pic := o.pic; pic != nil {
if o.state != o.nextState { return nil // Ignore flow until we don't have a pic any more
log.Printf("State transition: %v -> %v", o.state, o.nextState) }
switch o.nextState {
case StateInterface: // Setup, move state to interface if o.flow == nil {
if err := o.setupInterface(); err != nil { if err := o.setupFlow(); err != nil {
return err return fmt.Errorf("failed to setup UI flow: %v", err)
}
case StateExit:
{
return errExit
}
default:
return fmt.Errorf("Unknown state transition: %v -> %v", o.state, o.nextState)
} }
} }
// State transition is finished, hooray // Ensure music is doing the right thing
o.state = o.nextState if o.music != nil && o.music.IsPlaying() != o.config.Options.PlayMusic {
if o.config.Options.PlayMusic {
switch o.state { o.music.Rewind()
case StateInterface: o.music.Play()
return o.iface.Update(screenX, screenY) } else {
default: o.music.Pause()
return fmt.Errorf("Unknown state: %v", o.state) }
} }
return o.flow.Update(screenX, screenY)
} }
func (o *Ordoor) Draw(screen *ebiten.Image) error { func (o *Ordoor) Draw(screen *ebiten.Image) error {
switch o.state { if pic := o.pic; pic != nil {
case StateInterface: // Scale the picture to the screen and draw it
return o.iface.Draw(screen) scaleX := float64(screen.Bounds().Dx()) / float64(pic.Bounds().Dx())
default: scaleY := float64(screen.Bounds().Dy()) / float64(pic.Bounds().Dy())
return fmt.Errorf("Unknown state: %v", o.state)
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) { 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 { if len(o.config.VideoPlayer) == 0 {
log.Printf("Video player not configured, skipping video %v", filename) log.Printf("Video player not configured, skipping video %v", filename)

View File

@@ -1,12 +1,11 @@
package data package palettes
import "image/color" import "image/color"
var ( var (
Transparent = color.RGBA{R: 0, G: 0, B: 0, A: 0} ChaosGatePalette = color.Palette{
ColorPalette = color.Palette{
Transparent, Transparent,
color.RGBA{R: 128, G: 0, B: 0, A: 255}, color.RGBA{R: 128, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 128, 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: 128, G: 128, B: 0, A: 255},
@@ -264,3 +263,7 @@ var (
color.RGBA{R: 255, G: 255, B: 255, A: 255}, 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
}
}

19
internal/ui/animation.go Normal file
View File

@@ -0,0 +1,19 @@
package ui
import (
"github.com/hajimehoshi/ebiten"
)
var (
SpeedDivisor = 2
)
type animation []*ebiten.Image
func (a animation) image(tick int) *ebiten.Image {
if len(a) == 0 {
return nil
}
return a[(tick/SpeedDivisor)%len(a)]
}

165
internal/ui/buttons.go Normal file
View File

@@ -0,0 +1,165 @@
package ui
import (
"image"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// A button without hover animation
// FIXME: Keyboard.mnu has TypeSimpleButton instances that seem to include a
// hover in the SpriteId field
type button struct {
locator string
rect image.Rectangle
baseSpr *assetstore.Sprite
clickSpr *assetstore.Sprite
frozenSpr *assetstore.Sprite
clickImpl
freezeImpl
hoverImpl
}
// A button with hover animation
type mainButton struct {
hoverAnim animation
button
}
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 nil, nil, err
}
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 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{
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) buildDoorHotspot(p *menus.Properties) (*button, *Widget, error) {
sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 2) // base, pressed
if err != nil {
return nil, nil, err
}
btn := &button{
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: 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 (b *button) id() string {
return b.locator
}
func (b *button) bounds() image.Rectangle {
return b.rect
}
func (b *button) mouseDownState() bool {
if b.isFrozen() {
return false
}
return b.clickImpl.mouseDownState()
}
func (b *button) registerMouseClick() {
if !b.isFrozen() {
b.clickImpl.registerMouseClick()
}
}
func (b *button) regions(tick int) []region {
if b.isFrozen() {
return oneRegion(b.bounds().Min, b.frozenSpr.Image)
}
if b.mouseDownState() {
return oneRegion(b.bounds().Min, b.clickSpr.Image)
}
return oneRegion(b.bounds().Min, b.baseSpr.Image)
}
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))
}
return m.button.regions(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
}

271
internal/ui/driver.go Normal file
View File

@@ -0,0 +1,271 @@
package ui
import (
"fmt"
"image"
"runtime/debug"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/ebitenutil"
"code.ur.gs/lupine/ordoor/internal/assetstore"
)
const (
OriginalX = 640.0
OriginalY = 480.0
)
// 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
assets *assetstore.AssetStore
menu *assetstore.Menu
// 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
cursorScaled image.Point
// These two matrices are used for scaling between the two
orig2native ebiten.GeoM
native2orig ebiten.GeoM
ticks int // Used in animation effects
tooltip string
}
func NewDriver(assets *assetstore.AssetStore, menu *assetstore.Menu) (*Driver, error) {
driver := &Driver{
Name: menu.Name,
assets: assets,
menu: menu,
}
for _, group := range menu.Groups() {
if err := driver.registerGroup(group); err != nil {
return nil, err
}
}
return driver, nil
}
func (d *Driver) Update(screenX, screenY int) error {
if d == nil {
debug.PrintStack()
return fmt.Errorf("Tried to update a nil ui.Driver")
}
// This will be updated while processing hovers
d.tooltip = ""
d.ticks += 1
// Update translation matrices
d.orig2native.Reset()
d.orig2native.Scale(float64(screenX)/OriginalX, float64(screenY)/OriginalY)
d.native2orig = d.orig2native
d.native2orig.Invert()
// Update original and scaled mouse coordinates
mouseX, mouseY := ebiten.CursorPosition()
d.cursorScaled = image.Pt(mouseX, mouseY)
mnX, mnY := d.native2orig.Apply(float64(mouseX), float64(mouseY))
d.cursorOrig = image.Pt(int(mnX), int(mnY))
// Dispatch notifications to our widgets
for _, hoverable := range d.activeHoverables() {
inBounds := d.cursorOrig.In(hoverable.bounds())
d.hoverStartEvent(hoverable, inBounds)
d.hoverEndEvent(hoverable, inBounds)
if hoverable.hoverState() && hoverable.tooltip() != "" {
d.tooltip = hoverable.tooltip()
}
}
mouseIsDown := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft)
for _, clickable := range d.activeClickables() {
inBounds := d.cursorOrig.In(clickable.bounds())
mouseWasDown := clickable.mouseDownState()
d.mouseDownEvent(clickable, inBounds, mouseWasDown, mouseIsDown)
d.mouseClickEvent(clickable, inBounds, mouseWasDown, mouseIsDown)
d.mouseUpEvent(clickable, inBounds, mouseWasDown, mouseIsDown)
}
for _, mouseable := range d.activeMouseables() {
mouseable.registerMousePosition(d.cursorOrig)
}
return nil
}
func (d *Driver) Draw(screen *ebiten.Image) error {
if d == nil {
debug.PrintStack()
return fmt.Errorf("Tried to draw a nil ui.Driver")
}
var do ebiten.DrawImageOptions
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))
do.GeoM = d.orig2native
do.GeoM.Translate(x, y)
if err := screen.DrawImage(region.image, &do); err != nil {
return err
}
}
}
if d.tooltip != "" {
x, y := d.cursorScaled.X+16, d.cursorScaled.Y-16
ebitenutil.DebugPrintAt(screen, d.tooltip, x, y)
}
return nil
}
func (d *Driver) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
cursor, err := d.assets.Cursor(d.cursor)
if err != nil {
return nil, nil, 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()...)
}
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
}

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

@@ -1,162 +0,0 @@
package ui
import (
"fmt"
"image"
"log"
"reflect" // For DeepEqual
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/ebitenutil"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// type Interface encapsulates a user interface, providing a means to track UI
// state, draw the interface, and execute code when the widgets are interacted
// with.
//
// The graphics for UI elements were all created with a 640x480 resolution in
// mind. The interface transparently scales them all to the current screen size
// to compensate.
type Interface struct {
Name string
menu *assetstore.Menu
static []*assetstore.Sprite // Static elements in the interface
ticks int
widgets []*Widget
}
func NewInterface(menu *assetstore.Menu) (*Interface, error) {
iface := &Interface{
Name: menu.Name,
menu: menu,
}
for _, record := range menu.Records() {
if err := iface.addRecord(record); err != nil {
return nil, err
}
}
return iface, nil
}
// Find a widget by its hierarchical ID path
func (i *Interface) Widget(path ...int) (*Widget, error) {
for _, widget := range i.widgets {
if reflect.DeepEqual(path, widget.path) {
return widget, nil
}
}
return nil, fmt.Errorf("Couldn't find widget %#+v", path)
}
func (i *Interface) Update(screenX, screenY int) error {
// Used in animation effects
i.ticks += 1
mousePos := i.getMousePos(screenX, screenY)
// Iterate through all widgets, update mouse state
for _, widget := range i.widgets {
if widget.disabled {
continue // No activity for disabled widgets
}
mouseIsOver := mousePos.In(widget.Bounds)
widget.hovering(mouseIsOver)
widget.mouseButton(mouseIsOver && ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft))
}
return nil
}
func (i *Interface) Draw(screen *ebiten.Image) error {
geo := i.scale(screen.Size())
do := &ebiten.DrawImageOptions{GeoM: geo}
for _, sprite := range i.static {
do.GeoM.Translate(geo.Apply(float64(sprite.XOffset), float64(sprite.YOffset)))
if err := screen.DrawImage(sprite.Image, do); err != nil {
return err
}
do.GeoM = geo
}
for _, widget := range i.widgets {
img, err := widget.Image(i.ticks / 2)
if err != nil {
return err
}
if img == nil {
continue
}
do.GeoM.Translate(geo.Apply(float64(widget.Bounds.Min.X), float64(widget.Bounds.Min.Y)))
if err := screen.DrawImage(img, do); err != nil {
return err
}
do.GeoM = geo
if widget.hoverState && widget.Tooltip != "" {
mouseX, mouseY := ebiten.CursorPosition()
ebitenutil.DebugPrintAt(screen, widget.Tooltip, mouseX+16, mouseY-16)
}
}
return nil
}
func (i *Interface) addRecord(record *menus.Record) error {
log.Printf("Adding record: %#+v", record)
handler, ok := setupHandlers[record.Type]
if !ok {
return fmt.Errorf("ui.interface: encountered unknown menu record: %#+v", record)
}
if handler != nil {
if err := handler(i, record); err != nil {
return err
}
}
// Recursively add all children
for _, record := range record.Children {
if err := i.addRecord(record); err != nil {
return err
}
}
return nil
}
// Works out how much we have to scale the current screen by to draw correctly
func (i *Interface) scale(w, h int) ebiten.GeoM {
var geo ebiten.GeoM
geo.Scale(float64(w)/640.0, float64(h)/480.0)
return geo
}
func (i *Interface) unscale(w, h int) ebiten.GeoM {
geo := i.scale(w, h)
geo.Invert()
return geo
}
// Returns the current position of the mouse in 640x480 coordinates. Needs the
// actual size of the screen to do so.
func (i *Interface) getMousePos(w, h int) image.Point {
cX, cY := ebiten.CursorPosition()
geo := i.unscale(w, h)
sX, sY := geo.Apply(float64(cX), float64(cY))
return image.Pt(int(sX), int(sY))
}

149
internal/ui/interfaces.go Normal file
View File

@@ -0,0 +1,149 @@
package ui
import (
"image"
"github.com/hajimehoshi/ebiten"
)
type region struct {
offset image.Point
image *ebiten.Image
}
func oneRegion(offset image.Point, image *ebiten.Image) []region {
return []region{{offset: offset, image: image}}
}
type idable interface {
id() string
}
// Clickable can be clicked by the left button of a mouse. Specify code to run
// with OnClick().
type clickable interface {
idable
bounds() image.Rectangle
onClick(f func())
// These are used to drive the state of the item
mouseDownState() bool
setMouseDownState(bool)
registerMouseClick()
}
// This implements the clickable interface except id(), bounds(), and registerMouseClick()
type clickImpl struct {
f func()
mouseDown bool
}
func (c *clickImpl) onClick(f func()) {
c.f = f
}
func (c *clickImpl) mouseDownState() bool {
return c.mouseDown
}
func (c *clickImpl) setMouseDownState(down bool) {
c.mouseDown = down
}
func (c *clickImpl) registerMouseClick() {
if c.f != nil {
c.f()
}
}
// Freezable represents a widget that can be enabled or disabled
type freezable interface {
idable
isFrozen() bool
setFreezeState(bool)
}
// This implements the freezable interface except id()
type freezeImpl struct {
frozen bool
}
func (f *freezeImpl) isFrozen() bool {
return f.frozen
}
func (f *freezeImpl) setFreezeState(frozen bool) {
f.frozen = frozen
}
// Hoverable can be hovered over by the mouse cursor.
//
// If something can be hovered, it can have a tooltip, so that is implemented
// here too.
type hoverable interface {
bounds() image.Rectangle
tooltip() string
// These are used to drive the state of the item
hoverState() bool
setHoverState(bool)
}
// Implements the hoverable interface with the exception of bounds()
type hoverImpl struct {
hovering bool
text string
}
func (h *hoverImpl) tooltip() string {
return h.text
}
func (h *hoverImpl) hoverState() bool {
return h.hovering
}
func (h *hoverImpl) setHoverState(hovering bool) {
h.hovering = hovering
}
// Mouseables are told where on the (original) screen the mouse cursor is
type mouseable interface {
registerMousePosition(image.Point)
}
type mouseImpl struct {
pos image.Point
}
func (m *mouseImpl) registerMousePosition(pt image.Point) {
m.pos = pt
}
// Paintable encapsulates one or more regions to be painted to the screen
type paintable interface {
regions(tick int) []region
}
// Valueable encapsulates the idea of an element with a value. Only strings are
// supported - #dealwithit for bools, ints, etc
type valueable interface {
idable
value() string
setValue(string)
}
type valueImpl struct {
str string
}
func (v *valueImpl) value() string {
return v.str
}
func (v *valueImpl) setValue(value string) {
v.str = value
}

View File

@@ -0,0 +1,53 @@
package ui
import (
"code.ur.gs/lupine/ordoor/internal/menus"
)
// 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.
//
// TODO: wrap all the behaviour in a single struct to make it easier to drive
type inventorySelect struct {
checkbox
parentPath string
others []*inventorySelect
}
// Called from the menu, which fills "others" for us
func (d *Driver) buildInventorySelect(p *menus.Properties) (*inventorySelect, *Widget, error) {
c, _, err := d.buildCheckbox(p)
if err != nil {
return nil, nil, err
}
// In an inventorySelect, the frozen and click sprites are reversed
c.clickSpr, c.frozenSpr = c.frozenSpr, c.clickSpr
element := &inventorySelect{checkbox: *c}
widget := &Widget{
Active: p.Active,
ownClickables: []clickable{element},
ownFreezables: []freezable{element},
ownHoverables: []hoverable{element},
ownPaintables: []paintable{element},
ownValueables: []valueable{element},
}
return element, widget, nil
}
func (i *inventorySelect) registerMouseClick() {
// Do nothing if we're already selected
if i.value() == "1" {
return
}
// Turn us on, turn everyone else off
for _, other := range i.others {
other.setValue("0")
}
i.setValue("1")
}

186
internal/ui/list_box.go Normal file
View File

@@ -0,0 +1,186 @@
package ui
import (
"fmt"
"image"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// 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
// FIXME: can we share code between slider and this element?
thumbBase *assetstore.Sprite // Bounds are given by this
thumbImg *assetstore.Sprite // This is displayed at offset * (height / steps)
lines []*noninteractive // We display to these
// The list box acts as a window onto these
strings []string
// The start of our window
offset int
}
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, nil, err
}
downElem, downWidget, err := d.buildButton(down.Props())
if err != nil {
return nil, nil, err
}
thumbBaseSpr, err := d.menu.Sprite(thumb.ObjectIdx, thumb.Share)
if err != nil {
return nil, nil, err
}
thumbImgSpr, err := d.menu.Sprite(thumb.ObjectIdx, thumb.BaseSpriteID())
if err != nil {
return nil, nil, err
}
element := &listBox{
locator: group.Locator,
// TODO: upBtn needs to be frozen when offset == 0; downBtn when offset == max
upBtn: upElem,
downBtn: downElem,
// TODO: need to be able to drag the thumb
thumbBase: thumbBaseSpr,
thumbImg: thumbImgSpr,
}
// Internal wiring-up
upElem.onClick(element.up)
downElem.onClick(element.down)
// FIXME: Test data for now
for i := 0; i < 50; i++ {
element.strings = append(element.strings, fmt.Sprintf("FOO %v", i))
}
// Register everything. Since we're a composite of other controls, they are
// mostly self-registered at the moment.
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, niWidget, err := d.buildStatic(rec.Props())
if err != nil {
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 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) {
if len(to) < len(l.strings) {
l.offset = 0 // FIXME: unconditional? Trim to max?
}
l.strings = to
l.refresh()
}
// TODO: Selected returns the index and value of the selected item
func (l *listBox) Selected() (int, string) {
return 0, ""
}
func (l *listBox) up() {
if l.offset <= 0 {
return
}
l.offset -= 1
l.refresh()
}
func (l *listBox) down() {
if l.offset > len(l.strings)-len(l.lines) {
return
}
l.offset += 1
l.refresh()
}
func (l *listBox) refresh() {
for i, ni := range l.lines {
// FIXME: noninteractive isn't set up for dynamic text yet. Need to
// generate textImg on demand instead of once at start.
if ni.label != nil {
ni.label.text = ""
if len(l.strings) > l.offset+i {
ni.label.text = l.strings[l.offset+i]
}
}
}
}
func (l *listBox) thumbPos() image.Point {
pos := l.thumbImg.Rect.Min
if len(l.strings) == 0 {
return pos
}
pixPerLine := (l.thumbBase.Rect.Dy()) / (len(l.strings) - len(l.lines))
pos.Y += pixPerLine * l.offset
return pos
}
func (l *listBox) regions(tick int) []region {
// Draw the slider at the appropriate point
out := oneRegion(l.thumbBase.Rect.Min, l.thumbBase.Image)
out = append(out, oneRegion(l.thumbPos(), l.thumbImg.Image)...)
return out
}

View File

@@ -0,0 +1,302 @@
package ui
import (
"fmt"
"image"
"log"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
type AlignMode int
const (
AlignModeCentre AlignMode = 0
AlignModeLeft AlignMode = 1
)
// 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 {
locator string
frames animation
rect image.Rectangle
// Some non-interactives, e.g., overlays, are an image + text to be shown
label *label
clickImpl // Alright, alright, it turns out the bridge mission briefing is clickable
hoverImpl
}
// Paint some text to screen
type label struct {
align AlignMode
rect image.Rectangle
text string
font *assetstore.Font
}
// This particular animation has entry and exit sequences, which are invoked
// when entering and leaving hover, respectively. Example: bridge doors
type animationHover struct {
noninteractive // Use the frames in here for the "enter hover" animation
exitFrames animation // and here the "exit hover" animation
atTick int // Tracks progress through the frames
opening bool
closing bool
}
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(p.ObjectIdx, spriteId)
if err != nil {
return nil, err
}
ni := &noninteractive{
locator: p.Locator,
frames: animation{sprite.Image},
rect: sprite.Rect.Add(p.Point()),
}
return ni, nil
}
func (d *Driver) buildStatic(p *menus.Properties) (*noninteractive, *Widget, error) {
ni, err := d.buildNoninteractive(p)
if err != nil {
return nil, nil, err
}
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},
}
return ni, widget, 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 (d *Driver) buildOverlay(p *menus.Properties) (*noninteractive, *Widget, error) {
ni, err := d.buildNoninteractive(p)
if err != nil {
return nil, nil, err
}
widget := &Widget{
Locator: ni.locator,
Active: p.Active,
ownPaintables: []paintable{ni},
}
if p.Text != "" {
// FIXME: is this always right? Seems to make sense for Main.mnu
fnt := d.menu.Font(p.FontType/10 - 1)
ni.label = &label{
font: fnt,
rect: ni.rect, // We will be centered by default
text: p.Text,
}
} else {
log.Printf("Overlay without text detected in %v", p.Locator)
}
return ni, widget, nil
}
// An animation is a non-interactive element that displays something in a loop
func (d *Driver) buildAnimationSample(p *menus.Properties) (*noninteractive, *Widget, error) {
sprite, err := d.menu.Sprite(p.ObjectIdx, p.SpriteId[0])
if err != nil {
return nil, nil, err
}
frames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType)
if err != nil {
return nil, nil, err
}
ani := &noninteractive{
locator: p.Locator,
frames: animation(frames),
hoverImpl: hoverImpl{text: p.Text},
rect: sprite.Rect.Add(p.Point()),
}
widget := &Widget{
Active: p.Active,
ownHoverables: []hoverable{ani},
ownPaintables: []paintable{ani},
}
return ani, widget, nil
}
func (d *Driver) buildAnimationHover(p *menus.Properties) (*animationHover, *Widget, error) {
sprite, err := d.menu.Sprite(p.ObjectIdx, p.SpriteId[0])
if err != nil {
return nil, nil, err
}
enterFrames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType)
if err != nil {
return nil, nil, err
}
exitFrames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0]+p.DrawType, p.DrawType)
if err != nil {
return nil, nil, err
}
ani := &animationHover{
noninteractive: noninteractive{
locator: p.Locator,
frames: animation(enterFrames),
hoverImpl: hoverImpl{text: p.Text},
rect: sprite.Rect.Add(p.Point()),
},
exitFrames: animation(exitFrames),
}
widget := &Widget{
Active: p.Active,
ownHoverables: []hoverable{ani},
ownPaintables: []paintable{ani},
}
return ani, widget, nil
}
func (n *noninteractive) id() string {
return n.locator
}
func (n *noninteractive) bounds() image.Rectangle {
return n.rect
}
func (n *noninteractive) regions(tick int) []region {
out := oneRegion(n.bounds().Min, n.frames.image(tick))
// Text for a noninteractive is not registered separately
if n.label != nil {
out = append(out, n.label.regions(tick)...)
}
return out
}
func (a *animationHover) regions(tick int) []region {
if a.opening || a.closing {
var anim animation
if a.opening {
anim = a.frames
} else {
anim = a.exitFrames
}
out := oneRegion(a.bounds().Min, anim[a.atTick])
if a.atTick < len(anim)-1 {
a.atTick += 1
} else if !a.hoverState() {
a.closing = false
}
return out
}
// Nothing doing, show a closed door
return oneRegion(a.bounds().Min, a.frames.image(0))
}
func (a *animationHover) setHoverState(value bool) {
a.atTick = 0
a.opening = value
a.closing = !value
a.hoverImpl.setHoverState(value)
}
// Top-left of where to start drawing the text. We want it to appear to be in
// the centre of the rect.
//
// TODO: additional modes (left-aligned, especially)
func (l *label) pos() image.Point {
pos := l.rect.Min
textRect := l.font.CalculateBounds(l.text)
// Centre the text horizontally
if l.align == AlignModeCentre {
xSlack := l.rect.Dx() - textRect.Dx()
if xSlack > 0 {
pos.X += xSlack / 2
}
} else {
// FIXME: we're giving it 8pts of left to not look horrible
pos.X += 8
}
// Centre the text vertically
ySlack := l.rect.Dy() - textRect.Dy()
if ySlack > 0 {
pos.Y += ySlack / 2
}
return pos
}
func (l *label) regions(tick int) []region {
var out []region
pt := l.pos()
for _, r := range l.text {
glyph, err := l.font.Glyph(r)
if err != nil {
log.Printf("FIXME: ignoring misssing glyph %v", r)
continue
}
out = append(out, oneRegion(pt, glyph.Image)...)
pt.X += glyph.Rect.Dx()
}
return out
}

263
internal/ui/selectors.go Normal file
View File

@@ -0,0 +1,263 @@
package ui
import (
"image"
"math"
"strconv"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// A checkbox can be a fancy button
type checkbox struct {
button
valueImpl
}
// A slider is harder. Two separate elements to render
type slider struct {
locator string
rect image.Rectangle
baseSpr *assetstore.Sprite
clickSpr *assetstore.Sprite
sliderSpr *assetstore.Sprite
hv bool // horizontal (false) or vertical (true) slider
steps map[int]int // A list of valid steps. value:offset
clickImpl
mouseImpl
valueImpl
}
// A checkbox has 3 sprites, and 3 states: unchecked, checked, disabled.
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 nil, nil, err
}
checkbox := &checkbox{
button: button{
locator: p.Locator,
rect: sprites[0].Rect.Add(p.Point()),
baseSpr: sprites[0], // unchecked
clickSpr: sprites[2], // checked
frozenSpr: sprites[1], // disabled
hoverImpl: hoverImpl{text: p.Text},
},
valueImpl: valueImpl{str: "0"},
}
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{checkbox},
ownFreezables: []freezable{checkbox},
ownHoverables: []hoverable{checkbox},
ownPaintables: []paintable{checkbox},
ownValueables: []valueable{checkbox},
}
return checkbox, widget, nil
}
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 nil, nil, err
}
slider := &slider{
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
}
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{slider},
ownMouseables: []mouseable{slider},
ownPaintables: []paintable{slider},
ownValueables: []valueable{slider},
}
return slider, widget, nil
}
func (c *checkbox) registerMouseClick() {
if c.value() == "1" { // Click disables
c.setValue("0")
} else { // Click enables
c.setValue("1")
}
}
func (c *checkbox) regions(tick int) []region {
if c.isFrozen() {
return oneRegion(c.bounds().Min, c.frozenSpr.Image)
}
if c.value() == "1" {
return oneRegion(c.bounds().Min, c.clickSpr.Image)
}
return oneRegion(c.bounds().Min, c.baseSpr.Image)
}
func (s *slider) id() string {
return s.locator
}
// The bounds of the slider are the whole thing
func (s *slider) bounds() image.Rectangle {
return s.rect
}
func (s *slider) registerMouseClick() {
var value int
if s.hv {
value = s.valueFromPix(s.bounds().Min.Y, s.sliderPos().Y)
} else {
value = s.valueFromPix(s.bounds().Min.X, s.sliderPos().X)
}
s.valueImpl.str = strconv.Itoa(value)
s.clickImpl.registerMouseClick()
}
func (s *slider) regions(tick int) []region {
var out []region
if s.mouseDownState() {
out = append(out, oneRegion(s.bounds().Min, s.clickSpr.Image)...)
} else {
out = append(out, oneRegion(s.bounds().Min, s.baseSpr.Image)...)
}
out = append(out, oneRegion(s.sliderPos(), s.sliderSpr.Image)...)
return out
}
func (s *slider) sliderPos() image.Point {
if s.hv {
return s.sliderPosVertical()
}
return s.sliderPosHorizontal()
}
func (s *slider) sliderPosHorizontal() image.Point {
pos := s.bounds().Min
if s.mouseDownState() {
pos.X = s.constrainPix(s.bounds().Min.X, s.mouseImpl.pos.X)
} else {
pos.X = s.bounds().Min.X + s.offsetFromValue(s.valueInt())
}
return pos
}
func (s *slider) sliderPosVertical() image.Point {
pos := s.bounds().Min
if s.mouseDownState() {
pos.Y = s.constrainPix(s.bounds().Min.Y, s.mouseImpl.pos.Y)
} else {
pos.Y = s.bounds().Min.Y + s.offsetFromValue(s.valueInt())
}
return pos
}
func (s *slider) valueFromPix(start, actual int) int {
if len(s.steps) == 0 {
return actual - start
}
minDistance := 9999
var out int
for value, offset := range s.steps {
pix := start + offset
distance := int(math.Abs(float64(actual - pix)))
if distance < minDistance {
minDistance = distance
out = value
}
}
return out
}
func (s *slider) offsetFromValue(value int) int {
if len(s.steps) == 0 {
return value
}
value = s.constrainValue(value)
return s.steps[value]
}
func (s *slider) constrainPix(start, actual int) int {
if len(s.steps) == 0 {
return actual
}
minDistance := 9999
out := actual
for _, offset := range s.steps {
pix := start + offset
distance := int(math.Abs(float64(actual - pix)))
if distance < minDistance {
minDistance = distance
out = pix
}
}
return out
}
func (s *slider) constrainValue(actual int) int {
if len(s.steps) == 0 {
return actual
}
minDistance := 9999
out := actual
for value, _ := range s.steps {
distance := int(math.Abs(float64(value - actual)))
if distance < minDistance {
minDistance = distance
out = value
}
}
return out
}
func (s *slider) valueInt() int {
v, _ := strconv.Atoi(s.valueImpl.value())
return s.constrainValue(v)
}
func (s *slider) value() string {
return strconv.Itoa(s.valueInt())
}

View File

@@ -1,185 +0,0 @@
package ui
import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// Setup handlers know how to handle each type of widget
var setupHandlers = map[menus.MenuType]func(i *Interface, r *menus.Record) error {
menus.TypeStatic: handleStatic,
menus.TypeMenu: nil,
menus.TypeButton: handleButton,
menus.TypeInvokeButton: handleInvokeButton,
menus.TypeOverlay: handleStatic, // FIXME: more?
menus.TypeHypertext: nil, // FIXME: handle this
menus.TypeCheckbox: handleCheckbox,
menus.TypeAnimationSample: nil, // FIXME: handle this
menus.TypeMainButton: handleMainButton,
menus.TypeSlider: nil, // FIXME: handle this
}
func handleStatic(i *Interface, record *menus.Record) error {
spriteId := record.Share
// FIXME: SpriteID takes precedence over SHARE if present, but is that right?
if len(record.SpriteId) > 0 && record.SpriteId[0] != -1 {
spriteId = record.SpriteId[0]
}
sprite, err := i.menu.Sprite(spriteId)
if err != nil {
return err
}
i.static = append(i.static, sprite)
return nil
}
// A checkbox has 3 sprites, and 3 states: unchecked, checked, disabled.
func handleCheckbox(i *Interface, record *menus.Record) error {
widget, err := i.widgetFromRecord(record, record.Share)
if err != nil {
return err
}
unchecked := widget.sprite
disabled, err := i.menu.Sprite(record.Share + 1)
if err != nil {
return err
}
checked, err := i.menu.Sprite(record.Share + 2)
if err != nil {
return err
}
widget.Value = "0"
widget.OnMouseClick = func() {
if widget.Value == "1" { // Click disables
widget.Value = "0"
} else { // Click enables
widget.Value = "1"
}
}
widget.disabledImage = disabled.Image
widget.valueToImage = func() *ebiten.Image {
if widget.Value == "1" {
return checked.Image
}
return unchecked.Image
}
i.widgets = append(i.widgets, widget)
return nil
}
func handleButton(i *Interface, record *menus.Record) error {
spriteId := record.SpriteId[0]
widget, err := i.widgetFromRecord(record, spriteId)
if err != nil {
return err
}
pressed, err := i.menu.Sprite(spriteId + 1)
if err != nil {
return err
}
disabled, err := i.menu.Sprite(spriteId + 2)
if err != nil {
return err
}
widget.mouseButtonDownImage = pressed.Image
widget.disabledImage = disabled.Image
i.widgets = append(i.widgets, widget)
return nil
}
func handleInvokeButton(i *Interface, record *menus.Record) error {
widget, err := i.widgetFromRecord(record, record.Share)
if err != nil {
return err
}
pressed, err := i.menu.Sprite(record.Share + 1)
if err != nil {
return err
}
disabled, err := i.menu.Sprite(record.Share + 2)
if err != nil {
return err
}
widget.mouseButtonDownImage = pressed.Image
widget.disabledImage = disabled.Image
i.widgets = append(i.widgets, widget)
return nil
}
// A main button is quite complex. It has 3 main sprites and a hover animation
func handleMainButton(i *Interface, record *menus.Record) error {
widget, err := i.widgetFromRecord(record, record.Share)
if err != nil {
return err
}
pressed, err := i.menu.Sprite(record.Share + 1)
if err != nil {
return err
}
disabled, err := i.menu.Sprite(record.Share + 2)
if err != nil {
return err
}
hovers, err := i.menu.Images(record.SpriteId[0], record.DrawType)
if err != nil {
return err
}
widget.mouseButtonDownImage = pressed.Image
widget.disabledImage = disabled.Image
widget.hoverAnimation = hovers
i.widgets = append(i.widgets, widget)
return nil
}
// Widgets need a bounding box determined by a sprite. Different widgets specify
// their sprites in different attributes, so pass in the right sprite externally
func (i *Interface) widgetFromRecord(record *menus.Record, spriteId int) (*Widget, error) {
sprite, err := i.menu.Sprite(spriteId)
if err != nil {
return nil, err
}
var path []int
for r := record; r != nil; r = r.Parent {
path = append([]int{r.Id}, path...)
}
widget := &Widget{
Bounds: sprite.Rect,
Tooltip: record.Desc,
path: path,
record: record,
sprite: sprite,
}
return widget, nil
}

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)
}

View File

@@ -1,105 +1,142 @@
package ui package ui
import (
"image"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
// Widget represents an interactive area of the screen. Backgrounds and other
// non-interactive areas are not widgets.
type Widget struct { type Widget struct {
// Position on the screen in original (i.e., unscaled) coordinates Locator string
Bounds image.Rectangle Children []*Widget
Tooltip string Active bool
Value string // #dealwithit for bools and ints and so on :p
OnHoverEnter func() ownClickables []clickable
OnHoverLeave func() ownFreezables []freezable
ownHoverables []hoverable
// Mouse up can happen without a click taking place if, for instance, the ownMouseables []mouseable
// mouse cursor leaves the bounds while still pressed. ownPaintables []paintable
OnMouseDown func() ownValueables []valueable
OnMouseClick func()
OnMouseUp func()
disabled bool
disabledImage *ebiten.Image
// These are expected to have the same dimensions as the Bounds
hoverAnimation []*ebiten.Image
hoverState bool
// FIXME: We assume right mouse button isn't needed here
// TODO: down, up, and click hooks.
mouseButtonDownImage *ebiten.Image
mouseButtonState bool
path []int
record *menus.Record
sprite *assetstore.Sprite
valueToImage func() *ebiten.Image
} }
func (w *Widget) Disable() { func (w *Widget) allClickables() []clickable {
w.hovering(false) out := w.ownClickables
w.mouseButton(false)
w.disabled = true for _, widget := range w.Children {
out = append(out, widget.allClickables()...)
}
return out
} }
func (w *Widget) hovering(value bool) { func (w *Widget) allFreezables() []freezable {
if w.OnHoverEnter != nil && !w.hoverState && value { out := w.ownFreezables
w.OnHoverEnter()
for _, widget := range w.Children {
out = append(out, widget.allFreezables()...)
} }
if w.OnHoverLeave != nil && w.hoverState && !value { return out
w.OnHoverLeave()
}
w.hoverState = value
return
} }
func (w *Widget) mouseButton(value bool) { func (w *Widget) allValueables() []valueable {
if w.OnMouseDown != nil && !w.mouseButtonState && value { out := w.ownValueables
w.OnMouseDown()
for _, widget := range w.Children {
out = append(out, widget.allValueables()...)
} }
if w.mouseButtonState && !value { return out
if w.OnMouseClick != nil && w.hoverState { }
w.OnMouseClick()
}
if w.OnMouseUp != nil { func (w *Widget) activeClickables() []clickable {
w.OnMouseUp() 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
} }
} }
w.mouseButtonState = value return nil
}
func (w *Widget) Image(aniStep int) (*ebiten.Image, error) {
if w.disabled {
return w.disabledImage, nil
}
if w.mouseButtonDownImage != nil && w.hoverState && w.mouseButtonState {
return w.mouseButtonDownImage, nil
}
if w.hoverState && len(w.hoverAnimation) > 0 {
return w.hoverAnimation[(aniStep)%len(w.hoverAnimation)], nil
}
if w.valueToImage != nil {
return w.valueToImage(), nil
}
return w.sprite.Image, nil
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"runtime/debug"
"runtime/pprof" "runtime/pprof"
"github.com/hajimehoshi/ebiten" "github.com/hajimehoshi/ebiten"
@@ -17,15 +18,26 @@ type Game interface {
Draw(*ebiten.Image) error Draw(*ebiten.Image) error
} }
type CustomCursor interface {
// The cursor draw operation
Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error)
}
var ( var (
screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor") screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") 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 { type Window struct {
Title string Title string
KeyUpHandlers map[ebiten.Key]func() KeyUpHandlers map[ebiten.Key]func()
MouseWheelHandler func(float64, float64) MouseWheelHandler func(float64, float64)
MouseClickHandler func()
WhileKeyDownHandlers map[ebiten.Key]func()
// Allow the "game" to be switched out at any time // Allow the "game" to be switched out at any time
game Game game Game
@@ -44,13 +56,16 @@ func NewWindow(game Game, title string, xRes int, yRes int) (*Window, error) {
ebiten.SetRunnableInBackground(true) ebiten.SetRunnableInBackground(true)
return &Window{ 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()), KeyUpHandlers: make(map[ebiten.Key]func()),
debug: true,
firstRun: true,
game: game,
xRes: xRes,
yRes: yRes,
}, nil }, nil
} }
@@ -59,20 +74,62 @@ func (w *Window) OnKeyUp(key ebiten.Key, f func()) {
w.KeyUpHandlers[key] = f 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)) { func (w *Window) OnMouseWheel(f func(x, y float64)) {
w.MouseWheelHandler = f w.MouseWheelHandler = f
} }
func (w *Window) OnMouseClick(f func()) {
w.MouseClickHandler = f
}
func (w *Window) Layout(_, _ int) (int, int) { func (w *Window) Layout(_, _ int) (int, int) {
return w.xRes, w.yRes return w.xRes, w.yRes
} }
func (w *Window) Update(screen *ebiten.Image) error { 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() {
if panicErr := recover(); panicErr != nil {
if w.debug {
debug.PrintStack()
}
outErr = fmt.Errorf("Panic: %v", panicErr)
}
}()
if err := w.game.Update(screen.Size()); err != nil { if err := w.game.Update(screen.Size()); err != nil {
return err return err
} }
// Process keys // Process keys.
// FIXME: : should this happen before or after update?
// TODO: efficient set operations // TODO: efficient set operations
for key, cb := range w.KeyUpHandlers { for key, cb := range w.KeyUpHandlers {
@@ -81,6 +138,12 @@ func (w *Window) Update(screen *ebiten.Image) error {
} }
} }
for key, cb := range w.WhileKeyDownHandlers {
if ebiten.IsKeyPressed(key) {
cb()
}
}
if w.MouseWheelHandler != nil { if w.MouseWheelHandler != nil {
x, y := ebiten.Wheel() x, y := ebiten.Wheel()
if x != 0 || y != 0 { if x != 0 || y != 0 {
@@ -88,6 +151,12 @@ func (w *Window) Update(screen *ebiten.Image) error {
} }
} }
if w.MouseClickHandler != nil {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
w.MouseClickHandler()
}
}
if ebiten.IsDrawingSkipped() { if ebiten.IsDrawingSkipped() {
return nil return nil
} }
@@ -102,7 +171,8 @@ func (w *Window) Update(screen *ebiten.Image) error {
ebitenutil.DebugPrint(screen, msg) ebitenutil.DebugPrint(screen, msg)
} }
return nil // Draw the cursor last
return w.drawCursor(screen)
} }
// TODO: a stop or other cancellation mechanism // TODO: a stop or other cancellation mechanism

View File

@@ -4,6 +4,7 @@ package asciiscan
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"fmt"
"io" "io"
"os" "os"
"strconv" "strconv"
@@ -15,6 +16,9 @@ var hashComment = []byte("#")
type Scanner struct { type Scanner struct {
bufio *bufio.Scanner bufio *bufio.Scanner
closer io.Closer closer io.Closer
// If we've peeked, there will be items here
buffered []string
} }
func New(filename string) (*Scanner, error) { func New(filename string) (*Scanner, error) {
@@ -38,6 +42,13 @@ func (s *Scanner) Close() error {
} }
func (s *Scanner) ConsumeString() (string, 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() { for s.bufio.Scan() {
line := s.bufio.Bytes() line := s.bufio.Bytes()
@@ -68,15 +79,41 @@ func ConsumeProperty(s string) (string, string) {
} }
parts := strings.SplitN(s, ":", 2) parts := strings.SplitN(s, ":", 2)
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) 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 // Check to see if the line looks like a property (contains a colon character).
// (contain a colon character).
func IsProperty(s string) bool { func IsProperty(s string) bool {
return strings.Contains(s, ":") 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) { func (s *Scanner) ConsumeInt() (int, error) {
str, err := s.ConsumeString() str, err := s.ConsumeString()
if err != nil { if err != nil {
@@ -86,6 +123,48 @@ func (s *Scanner) ConsumeInt() (int, error) {
return strconv.Atoi(str) 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 { func (s *Scanner) ConsumeIntPtr(to *int) error {
val, err := s.ConsumeInt() val, err := s.ConsumeInt()
if err != nil { if err != nil {
@@ -96,6 +175,16 @@ func (s *Scanner) ConsumeIntPtr(to *int) error {
return nil 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 { func (s *Scanner) ConsumeIntPtrs(ptrs ...*int) error {
for _, ptr := range ptrs { for _, ptr := range ptrs {
if err := s.ConsumeIntPtr(ptr); err != nil { if err := s.ConsumeIntPtr(ptr); err != nil {
@@ -105,3 +194,13 @@ func (s *Scanner) ConsumeIntPtrs(ptrs ...*int) error {
return nil 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) def self.parse(data)
hdr = new(*data[0..SIZE - 1].unpack("VVVVV")) hdr = new(*data[0..SIZE - 1].unpack("VVVVV"))
pp hdr # pp hdr
hdr.validate!(data.bytes.size) hdr.validate!(data.bytes.size)
hdr hdr
end end
@@ -96,7 +96,7 @@ module Obj
DirEntry.parse(rel_data.byteslice(rel_offset, DirEntry::SIZE)) DirEntry.parse(rel_data.byteslice(rel_offset, DirEntry::SIZE))
end end
pp entries # pp entries
new(entries) new(entries)
end end
@@ -380,6 +380,18 @@ def correlate(filenames)
pp results pp results
end 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) def sprites(filename)
obj = load_obj(filename) obj = load_obj(filename)
@@ -499,7 +511,25 @@ def build(filename)
File.open(filename, "w") { |f| f.write(built.to_data) } File.open(filename, "w") { |f| f.write(built.to_data) }
end 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 case command = ARGV.shift
when "directory" then
directory(ARGV[0], ARGV[1].to_i)
when "unknown16" then
unknown16(ARGV)
when "sprites" then when "sprites" then
ARGV.each { |filename| sprites(filename) } ARGV.each { |filename| sprites(filename) }
when "sprite" then when "sprite" then