Compare commits

...

115 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
971b3178d6 Implement the options menu, part 1
This commit implements loading and saving options from/to config, and
enough UI toolkit magic to allow changes to boolean options to be
persisted.

We now respect the "play movies" setting and take screen resolution
from the config file.
2020-03-22 22:12:59 +00:00
0adbfaa573 Remove the -win-x and -win-y options for the ordoor binary 2020-03-22 22:12:20 +00:00
cfa56a0e12 Implement the main menu for the ordoor binary
In this commit, we also remove code that doesn't properly belong in
view-menu
2020-03-22 19:12:44 +00:00
d4d8a50ce4 Catch one more use of WH40K 2020-03-22 17:56:24 +00:00
3cb32b8962 Adjustments following kind discussion with LunarJetman on IRC 2020-03-22 17:19:26 +00:00
ba7c06e5fd Show tooltips when hovering 2020-03-22 15:37:48 +00:00
bfe9fbdf7d Start work on menu interactivity.
With this commit, we get a ui.Interface and ui.Widget type. The
interface monitors hover and mouse click state and tells the widgets
about them; the widgets execute code specified by the application when
events occur.

Next step: have wh40k load the main menu and play sound, etc.
2020-03-22 02:58:52 +00:00
bcaf3d9b58 Get view-menu to play the interface sound 2020-03-21 23:45:51 +00:00
83aa1c4768 Remove unwanted debugging prints 2020-03-21 23:14:15 +00:00
46925c09d1 Make the menu buttons work 2020-03-21 18:50:26 +00:00
be4229b8fe Remove internal/conv
This sets font rendering back a little bit, but not much.
2020-03-21 13:37:20 +00:00
4c0355ac4f HAXXX: Make videos skippable 2020-03-21 12:05:22 +00:00
8d48da1999 Fix bounds clipping 2020-03-21 11:48:29 +00:00
8b02f534f1 Eager load used sprites 2020-03-21 10:59:07 +00:00
7a8e9dbd97 Respect sprite X and Y offsets
This makes menus display more correctly, and also fixes trees and other
objects on the main map, although it messes up bounds clipping (sigh).
2020-03-21 00:56:35 +00:00
eb5c4430e8 go get -u 2020-03-20 00:38:35 +00:00
98fd06edd1 Forbid z index 7 2020-03-20 00:04:47 +00:00
e7d9083e6b Reorder drawn objects 2020-03-20 00:03:03 +00:00
576ac0570d Better logging 2020-03-20 00:02:27 +00:00
5fccf97f4b Lazily load sprite image data
This cuts memory use significantly, since many sprites in an object are
never used. We can get savings over time by evicting sprites when they
go out of scope, but that's, well, out of scope.

To achieve this, I introduce an assetstore package that is in charge of
loading things from the filesystem. This also allows some lingering
case-sensitivity issues to be handled cleanly.

I'd hoped that creating fewer ebiten.Image instances would help CPU
usage, but that doesn't seem to be the case.
2020-03-19 22:24:21 +00:00
34d12edc2a Use palettized images 2020-03-19 19:02:32 +00:00
d50a160f02 Use screen scaling 2020-03-19 18:44:51 +00:00
276885298a Allow ebiten.Run execution to be profiled 2020-03-19 18:36:20 +00:00
73553cb8b0 Bounds checking 2020-03-18 23:23:09 +00:00
9e3389c8ad Update ebiten 2020-03-18 20:55:01 +00:00
65249b59c4 Rename go module 2019-12-31 01:55:58 +00:00
5b4ad2495f go mod tidy 2019-12-30 00:55:34 +00:00
e90bea4513 ebiten: convert view-map 2019-12-30 00:51:20 +00:00
c54ead71f3 ebiten: convert view-menu 2019-12-29 23:47:22 +00:00
7475bdf0e7 ebiten: convert view-set 2019-12-29 20:41:41 +00:00
77202d9fab ui: Add fps, tps display 2019-12-29 20:34:07 +00:00
32b722ae88 ebiten: Convert view-minimap 2019-12-29 19:44:36 +00:00
1403580167 build: use a $(GOBUILD) 2019-12-29 19:44:26 +00:00
51e066ade1 ui: run in background 2019-12-29 19:41:20 +00:00
d1a1c50afc ui: event handlers 2019-12-29 17:30:21 +00:00
6f605aa502 ebiten: convert view-obj 2019-12-29 15:38:49 +00:00
104 changed files with 8896 additions and 2784 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
/wh40k
/investigation/Maps
/investigation/Obj

View File

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

192
README.md
View File

@@ -1,25 +1,111 @@
# Ordoor # Ordoor
Portmanteau of Order Door, a remake project for Warhammer 40,000: Chaos Gate, Ordoor is an **unofficial** [game engine recreation](https://en.wikipedia.org/wiki/Game_engine_recreation)
the game from 1998. 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** Four games are known to have been published for this engine:
No game yet, nothing even close. I'm in the very early stages of trying to * [Wages of War: The Business of Battle](https://en.wikipedia.org/wiki/Wages_of_War) (1996)
understand the various file formats. Until then, you can play WH40K: Chaos Gate * [Soldiers At War](https://en.wikipedia.org/wiki/Soldiers_at_War) (1998)
in a WinXP VM, disconnected from the internet. It doesn't need 3D rendering! * [Warhammer 40,000: Chaos Gate](https://en.wikipedia.org/wiki/Warhammer_40,000:_Chaos_Gate) (1998) [GOG](https://www.gog.com/game/warhammer_40000_chaos_gate)
* [Avalon Hill's Squad Leader](https://en.wikipedia.org/wiki/Avalon_Hill%27s_Squad_Leader) (2000)
WH40K.exe is the existing game engine, and WH40K_TD.exe is the map editor. The aim of Ordoor is to be a complete reimplementation that allows all four
Allows things to be saved as .MAP or as .SMF ("Super Macro File"). of these games to be played on modern hardware. It should also permit new games
of the same style to be built.
For each of the games above, **You must have a copy of the original game data to play**.
Links are provided above if we're aware of an active publisher; otherwise, check
your back catalogue, or perhaps a local charity shop.
Trademarks and intellectual property are the property of their respective
owners, and the games mentioned above (including the game data) are protected by
copyright. As a mere game engine recreation, we're confident that this project
operates legally, and that its goal is a noble one. Do get in touch if you
believe otherwise!
Ordoor is a portmanteau of Order Door, which is, of course, the opposite of a
Chaos Gate. The project began with a Chaos Gate recreation, then more games were
discovered, so scope expanded. A rename and/or rewrite may be on the cards as a
result.
## Current status
### Chaos Gate
Some of the original file formats are either partially or fully decoded. Maps,
menus, and most visual data can be rendered pixel-perfect. Sound can be played
(with a preprocessing step). Some UI tookit work is done. No game mechanics are
implemented yet.
I keep a GIF showcasing interesting progress here:
![](https://ur.gs/img/ordoor-main-menu.gif)
I've just been informed that another game from 1998, [Soldiers At War](https://en.wikipedia.org/wiki/Soldiers_at_War),
seems to use the same engine. Maybe at some point Ordoor will be able to play
both. Will that need a rename? Hmm. Watch this space.
### Soldiers At War
(At least some) objects display. Map support is being worked on in the
`soldiers-at-war` branch, which can more-or-less display them, albeit with many
errors.
### Squad Leader
Squad Leader is the most recent of the games created with this engine. Nothing
has been done with it yet, but a preliminary look at the game data suggests many
changes are afoot. The object files are a different format, at the very least.
### Wages of War
This is the oldest of the four games. The object file format seems to be mostly
the same. the installer only copies some data to the game directory; we may want
to work directly from the CDROM instead, if we can.
Maps are uncompressed, around 243K, and no header is present. They look similar
in principle to the tile data of Soldiers At War or Chaos Gate maps, otherwise.
The menu system seen in Chaos Gate is not present; instead, there is a `BUTTONS`
directory and a lot of `pcx` files under `PIC` that, I suspect, do the job for
this game.
Even with a full installation, Wages of War leaves a lot of data on the CD. It
may be best to run solely from the `WOW` directory on the CD, assuming it's a
strict superset of what gets installed, data-wise.
## Long-term goals
Once full playthrough of the official single-player campaign for all four games
has been achieved, thoughts turn to other things we could do. Here are some
ideas, mostly at random.
Multi-player support.
Graphics enhancements - 3D models instead of sprites, high-resolution tile sets,
32-bit colour, etc. Hopefully we'd be able to drop these in one at a time.
Vastly improved AI.
Mash-ups? How do mercenaries fare against cultists fare against Nazis? Only one
way to find out!
New campaigns with existing assets. Tell new stories, or elaborate on / modify
existing ones.
Completely new fantasy game using the same engine.
## Building from source ## 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
go version go1.13 linux/amd64 go version go1.14 linux/amd64
``` ```
In addition, you'll also need the following packages installed, at least in In addition, you'll also need the following packages installed, at least in
@@ -27,37 +113,31 @@ Debian:
``` ```
# apt install libx11-dev libxcursor-dev mesa-common-dev libxrandr-dev \ # apt install libx11-dev libxcursor-dev mesa-common-dev libxrandr-dev \
libxinerama-dev libgl1-mesa-dev libxi-dev mpv libxinerama-dev libgl1-mesa-dev libxi-dev libasound2-dev mpv ffmpeg
``` ```
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 hte moment. present at the moment.
They're not very interesting :D. ## Configuring
Place your WH40K: Chaos Gate installation in `./orig` to benefit from automatic Since we support multiple games, a fair bit of configuration is required. Copy
path defaults. Otherwise, point to it with `-game-path` `config.toml.example` to `config.toml` and edit it to your requirements. The
`data_dir` for the engine(s) you want to use is probably the most important bit,
along with the `default_engine`.
The `view-map` binary attempts to render a map, and is the current focus of The various games all use snapshots of the original engine at different points
effort. Once I can render a whole map, including pre-placed characters (cultist in time, and specify a lot in code that we need to specify in data. That should
scum), things can start to get more interesting. all go into the config file, so new games will be able to adapt the engine to
their needs.
Current status: map tiles are rendered at correct offsets. Static objects (four ## Running
per map coordinate: floor, centre, left, and right) are rendered mostly fine,
although objects at each Z level don't *quite* stack correctly on top of each
other yet.
Characters and animations aren't touched at all yet. Rendering performance is
atrocious. No gameplay, no sound, 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 orig/Maps/Chapter01.MAP -txt orig/Maps/Chapter01.TXT $ ./bin/view-map -map Chapter01
``` ```
Looks like this: Looks like this:
@@ -67,42 +147,40 @@ 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 `wh40k` binary:
``` ```
$ make wh40k make view-menu
$ ./wh40k ./bin/view-menu -menu Main
``` ```
This plays the introductory video so far, and nothing else. I'm hopeful I can This renders the menus found in Chaos Gate and Soldiers At War. The Squad Leader
render the main menu next. 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.
## Miscellany For Chaos Gate, there is the **start** of the game in an `ordoor` binary:
"Mission Setup" includes information about available squad types ```
$ make ordoor
$ ./bin/ordoor
```
From EquipDef.cpp Dumo: CEquipment we learn the following object types: 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.
0. DELETED ## Sound
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".... Sound is in the very early stages. Chaos Gate uses ADPCM WAV files, which are a
pain to play in Go, so for now, a preprocessing step that converts them to .ogg
is used instead. To create ./orig/Wav/*.wav.ogg, run:
0. CHARACTER ```
1. VEHICLE # apt install ffmpeg
2. CANISTER $ ./scripts/convert-wav ./orig/Wav
```
I'm starting to see some parallels with [this](https://github.com/shlainn/game-file-formats/wiki/) As with video playback, the ambition is to *eventually* remove this dependency
in the data formats, and the timeline (1997) seems about right. Worth keeping an and operate on the unmodified files instead.
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"
"ur.gs/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/config"
"ur.gs/ordoor/internal/fonts" "code.ur.gs/lupine/ordoor/internal/data"
"ur.gs/ordoor/internal/maps" "code.ur.gs/lupine/ordoor/internal/fonts"
"ur.gs/ordoor/internal/menus" "code.ur.gs/lupine/ordoor/internal/idx"
"ur.gs/ordoor/internal/sets" "code.ur.gs/lupine/ordoor/internal/maps"
"code.ur.gs/lupine/ordoor/internal/menus"
"code.ur.gs/lupine/ordoor/internal/palettes"
"code.ur.gs/lupine/ordoor/internal/sets"
) )
var ( 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)
}
}

27
cmd/ordoor/main.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"flag"
"log"
"os"
"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() {
configFile := "config.toml"
if len(os.Args) == 2 {
configFile = os.Args[1]
}
if err := ordoor.Run(configFile, *winX, *winY); err != nil {
log.Fatalf(err.Error())
}
os.Exit(0)
}

View File

@@ -5,7 +5,7 @@ import (
"os" "os"
"strconv" "strconv"
"ur.gs/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,313 +2,114 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"math" "math"
"os" "os"
"path/filepath"
"time"
"github.com/faiface/pixel" "github.com/hajimehoshi/ebiten"
"github.com/faiface/pixel/pixelgl"
"golang.org/x/image/colornames"
"ur.gs/ordoor/internal/conv" "code.ur.gs/lupine/ordoor/internal/assetstore"
"ur.gs/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/config"
"ur.gs/ordoor/internal/maps" "code.ur.gs/lupine/ordoor/internal/scenario"
"ur.gs/ordoor/internal/sets" "code.ur.gs/lupine/ordoor/internal/ui"
"ur.gs/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")
mapFile = flag.String("map", "", "Prefix path to a .map file, e.g. ./orig/Maps/Chapter01.MAP") engine = flag.String("engine", "", "Override engine to use")
txtFile = flag.String("txt", "", "Prefix path to a .txt file, e.g. ./orig/Maps/Chapter01.txt")
gameMap = flag.String("map", "", "Name of a map, e.g., Chapter01")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
) )
type env struct { type env struct {
gameMap *maps.GameMap scenario *scenario.Scenario
set *sets.MapSet
objects map[string]*conv.Object
sprites map[string][]*pixel.Sprite
batch *pixel.Batch
}
type state struct {
env *env
step int
fpsTicker <-chan time.Time
cam pixel.Matrix
camPos pixel.Vec
zoom float64
rot float64
zIdx int
} }
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || *mapFile == "" || *txtFile == "" { if *configFile == "" || *gameMap == "" {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
gameMap, err := maps.LoadGameMapByFiles(*mapFile, *txtFile) cfg, err := config.Load(*configFile, *engine)
if err != nil { if err != nil {
log.Fatalf("Couldn't load map file: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
setFile := filepath.Join(*gamePath, "Sets", gameMap.MapSetFilename()) assets, err := assetstore.New(cfg.DefaultEngine())
log.Println(setFile)
mapSet, err := sets.LoadSet(setFile)
if err != nil { if err != nil {
log.Fatalf("Couldn't load set file %s: %v", setFile, err) log.Fatalf("Failed to scan root directory: %v", err)
} }
rawObjs := []*data.Object{} scenario, err := scenario.NewScenario(assets, *gameMap)
for _, name := range mapSet.Palette { if err != nil {
objFile := filepath.Join(*gamePath, "Obj", name+".obj") log.Fatalf("Failed to load scenario %v: %v", *gameMap, err)
obj, err := data.LoadObject(objFile)
if err != nil {
log.Fatalf("Failed to load %s: %v", name, err)
}
obj.Name = name
rawObjs = append(rawObjs, obj)
} }
objects, spritesheet := conv.ConvertObjects(rawObjs)
batch := pixel.NewBatch(&pixel.TrianglesData{}, spritesheet)
env := &env{ env := &env{
gameMap: gameMap, scenario: scenario,
set: mapSet,
objects: conv.MapByName(objects),
batch: batch,
} }
// The main thread now belongs to pixelgl win, err := ui.NewWindow(env, "View Map "+*gameMap, *winX, *winY)
pixelgl.Run(env.run)
}
func (e *env) run() {
title := "View Map " + *mapFile
win, err := ui.NewWindow(title + " | FPS: ?")
if err != nil { if err != nil {
log.Fatal("Couldn't create window: %v", err) log.Fatal("Couldn't create window: %v", err)
} }
pWin := win.PixelWindow step := 32
state := &state{ win.WhileKeyDown(ebiten.KeyLeft, env.changeOrigin(-step, +0))
env: e, win.WhileKeyDown(ebiten.KeyRight, env.changeOrigin(+step, +0))
// camPos: pixel.V(0, float64(-pWin.Bounds().Size().Y)), win.WhileKeyDown(ebiten.KeyUp, env.changeOrigin(+0, -step))
// camPos: pixel.V(float64(3700), float64(0)), win.WhileKeyDown(ebiten.KeyDown, env.changeOrigin(+0, +step))
zoom: 1.0,
rot: 0.785,
step: -1,
fpsTicker: time.Tick(time.Second),
}
pWin.SetSmooth(true)
win.Run(func() { for i := 0; i <= 6; i++ {
oldState := *state win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i))
state = state.runStep(pWin)
if oldState != *state || oldState.step == -1 {
log.Printf("zoom=%.2f rot=%.4f zIdx=%v camPos=%#v", state.zoom, state.rot, state.zIdx, state.camPos)
state.present(pWin)
}
state.step += 1
select {
case <-state.fpsTicker:
pWin.SetTitle(fmt.Sprintf("%s | FPS: %d", title, state.step))
state.step = 0
default:
}
})
}
func (e *env) getSprite(palette []string, ref maps.ObjRef) (*conv.Sprite, error) {
// There seems to be an active bit that hides many sins
if !ref.IsActive() {
return nil, nil
} }
if ref.Index() >= len(palette) { win.OnMouseClick(env.showCellData)
return nil, fmt.Errorf("Palette too small: %v requested", ref.Index()) win.OnMouseWheel(env.changeZoom)
}
name := palette[ref.Index()] if err := win.Run(); err != nil {
log.Fatal(err)
obj := e.objects[name]
if obj == nil {
return nil, fmt.Errorf("Failed to find surface sprite %#v -> %q", ref, name)
}
if ref.Sprite() >= len(obj.Sprites) {
return nil, fmt.Errorf("Out-of-index sprite %v requested for %v", ref.Sprite(), name)
}
return obj.Sprites[ref.Sprite()], nil
}
func (s *state) present(pWin *pixelgl.Window) {
pWin.Clear(colornames.Black)
s.env.batch.Clear()
center := pWin.Bounds().Center()
cam := pixel.IM
cam = cam.ScaledXY(center, pixel.Vec{1.0, -1.0}) // invert the Y axis
cam = cam.Scaled(center, s.zoom) // apply current zoom factor
cam = cam.Moved(center.Sub(s.camPos)) // Make it central
s.cam = cam
pWin.SetMatrix(cam)
// TODO: we should be able to perform bounds clipping on these
minX := int(s.env.gameMap.MinWidth)
maxX := int(s.env.gameMap.MaxWidth)
minY := int(s.env.gameMap.MinLength)
maxY := int(s.env.gameMap.MaxLength)
minZ := 0
maxZ := int(s.zIdx) + 1
for z := minZ; z < maxZ; z++ {
for y := minY; y < maxY; y++ {
for x := minX; x < maxX; x++ {
s.renderCell(x, y, z, s.env.batch)
}
}
}
s.env.batch.Draw(pWin)
pWin.Update()
}
func (s *state) renderCell(x, y, z int, target pixel.Target) {
var sprites []*conv.Sprite
cell := s.env.gameMap.Cells.At(x, y, z)
if spr, err := s.env.getSprite(s.env.set.Palette, cell.Surface); err != nil {
log.Printf("%v %v %v surface: %v", x, y, z, err)
} else if spr != nil {
sprites = append(sprites, spr)
}
if spr, err := s.env.getSprite(s.env.set.Palette, cell.Center); err != nil {
log.Printf("%v %v %v center: %v", x, y, z, err)
} else if spr != nil {
sprites = append(sprites, spr)
}
if spr, err := s.env.getSprite(s.env.set.Palette, cell.Left); err != nil {
log.Printf("%v %v %v left: %v", x, y, z, err)
} else if spr != nil {
sprites = append(sprites, spr)
}
if spr, err := s.env.getSprite(s.env.set.Palette, cell.Right); err != nil {
log.Printf("%v %v %v right: %v", x, y, z, err)
} else if spr != nil {
sprites = append(sprites, spr)
}
// Taking the Z index away *seems* to draw the object in the correct place.
// FIXME: There are some artifacts, investigate more
fX := float64(x)
fY := float64(y)
iso := s.cellToPix(pixel.V(fX, fY))
iso = iso.Add(pixel.Vec{0.0, -float64(z * 48.0)})
for _, sprite := range sprites {
sprite.Spr.Draw(target, pixel.IM.Moved(iso))
} }
} }
var ( func (e *env) Update(screenX, screenY int) error {
cellWidth = 64.0 return e.scenario.Update(screenX, screenY)
cellHeight = 64.0
)
// Doesn't take the camera or Z level into account
func (s *state) cellToPix(cell pixel.Vec) pixel.Vec {
return pixel.V(
(cell.X-cell.Y)*cellWidth,
(cell.X+cell.Y)*cellHeight/2.0,
)
} }
// Doesn't take the camera or Z level into account func (e *env) Draw(screen *ebiten.Image) error {
func (s *state) pixToCell(pix pixel.Vec) pixel.Vec { return e.scenario.Draw(screen)
return pixel.V(
pix.Y/cellHeight+pix.X/(cellWidth*2.0),
pix.Y/cellHeight-pix.X/(cellWidth*2.0),
)
} }
func (s *state) runStep(pWin *pixelgl.Window) *state { func (e *env) changeOrigin(byX, byY int) func() {
newState := *s return func() {
newState.handleKeys(pWin) e.scenario.Viewpoint.X += byX
e.scenario.Viewpoint.Y += byY
return &newState }
} }
func (s *state) handleKeys(pWin *pixelgl.Window) { func (e *env) changeZoom(_, byY float64) {
// Do this first to avoid taking the below mutations into account e.scenario.Zoom *= math.Pow(1.2, byY)
// FIXME: this suggests we should pass the next state into here and }
// modify it instead
if pWin.JustPressed(pixelgl.MouseButton1) { func (e *env) setZIdx(to int) func() {
if s.zIdx != 0 { return func() {
log.Printf("WARNING: z-index not yet taken into account") e.scenario.ZIdx = to
} }
}
log.Printf("cam: %#v", s.cam)
func (e *env) showCellData() {
pos := s.pixToCell(s.cam.Unproject(pWin.MousePosition())) screenX, screenY := ebiten.CursorPosition()
log.Printf("X=%v Y=%v, zIdx=%v", pos.X, pos.Y, s.zIdx) viewX, viewY := e.scenario.Viewpoint.X+screenX, e.scenario.Viewpoint.Y+screenY
cell := s.env.gameMap.Cells.At(int(pos.X), int(pos.Y), s.zIdx) log.Printf("Click registered at (%d,%d) screen, (%d,%d) virtual", screenX, screenY, viewX, viewY)
log.Printf("Cell=%#v", cell)
} cell, pos := e.scenario.CellAtCursor()
log.Printf("Viewpoint: %#+v z=%v", e.scenario.Viewpoint, e.scenario.ZIdx)
if pWin.Pressed(pixelgl.KeyLeft) { log.Printf("Cell under cursor: (%.2f,%.2f,%d): %#+v", pos.X, pos.Y, pos.Z, cell)
s.camPos.X -= 64
}
if pWin.Pressed(pixelgl.KeyRight) {
s.camPos.X += 64
}
if pWin.Pressed(pixelgl.KeyDown) {
s.camPos.Y -= 64
}
if pWin.Pressed(pixelgl.KeyUp) {
s.camPos.Y += 64
}
for i := 1; i <= 7; i++ {
if pWin.JustPressed(pixelgl.Key0 + pixelgl.Button(i)) {
s.zIdx = i - 1
}
}
if pWin.Pressed(pixelgl.KeyMinus) {
s.rot -= 0.001
}
if pWin.Pressed(pixelgl.KeyEqual) {
s.rot += 0.001
}
// Zoom in and out with the mouse wheel
s.zoom *= math.Pow(1.2, pWin.MouseScroll().Y)
} }

View File

@@ -2,267 +2,97 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"os" "os"
"path/filepath"
"github.com/faiface/pixel" "github.com/hajimehoshi/ebiten"
"github.com/faiface/pixel/pixelgl"
"golang.org/x/image/colornames"
"ur.gs/ordoor/internal/conv" "code.ur.gs/lupine/ordoor/internal/assetstore"
"ur.gs/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/config"
"ur.gs/ordoor/internal/fonts" "code.ur.gs/lupine/ordoor/internal/ui"
"ur.gs/ordoor/internal/menus"
"ur.gs/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")
menuFile = flag.String("menu", "", "Path to a .mnu file, e.g. ./orig/Menu/MainGame.mnu") engine = flag.String("engine", "", "Override engine to use")
menuName = flag.String("menu", "", "Name of a menu, e.g. Main")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
) )
type env struct { type dlg struct {
menu *menus.Menu driver *ui.Driver
objects []*conv.Object list []string
batch *pixel.Batch pos int
fonts []*conv.Font
fontObjs []*conv.Object
fontBatch *pixel.Batch
}
type state struct {
env *env
cam pixel.Matrix
step int
// Redraw the window if these change
winPos pixel.Vec
winBounds pixel.Rect
}
func loadObjects(names ...string) ([]*conv.Object, *pixel.Batch) {
var raw []*data.Object
for _, name := range names {
objFile := filepath.Join(filepath.Dir(*menuFile), name)
obj, err := data.LoadObject(objFile)
if err != nil {
log.Fatalf("Failed to load %s: %v", name, err)
}
obj.Name = name
raw = append(raw, obj)
}
objects, spritesheet := conv.ConvertObjects(raw)
batch := pixel.NewBatch(&pixel.TrianglesData{}, spritesheet)
return objects, batch
} }
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || *menuFile == "" { if *configFile == "" || *menuName == "" {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
menu, err := menus.LoadMenu(*menuFile) cfg, err := config.Load(*configFile, *engine)
if err != nil { if err != nil {
log.Fatalf("Couldn't load menu file %s: %v", *menuFile, err) log.Fatalf("Failed to load config: %v", err)
} }
if i18n, err := data.LoadI18n(filepath.Join(*gamePath, "Data", data.I18nFile)); err != nil { assets, err := assetstore.New(cfg.DefaultEngine())
log.Printf("Failed to load i18n data, skipping internationalization: %v", err)
} else {
menu.Internationalize(i18n)
}
loadedFonts, err := loadFonts(menu.FontNames...)
if err != nil { if err != nil {
log.Fatalf("Failed to load font: %v", err) log.Fatal(err)
} }
menuObjs, menuBatch := loadObjects(menu.ObjectFiles...) menu, err := assets.Menu(*menuName)
if err != nil {
env := &env{ log.Fatalf("Couldn't load menu %s: %v", *menuName, err)
menu: menu, objects: menuObjs, batch: menuBatch,
fonts: loadedFonts,
} }
// The main thread now belongs to pixelgl driver, err := ui.NewDriver(assets, menu)
pixelgl.Run(env.run) if err != nil {
} log.Fatalf("Couldn't initialize interface: %v", err)
func loadFonts(names ...string) ([]*conv.Font, error) {
var out []*conv.Font
for _, name := range names {
fnt, err := fonts.LoadFont(filepath.Join(*gamePath, "Fonts", name+".fnt"))
if err != nil {
return nil, fmt.Errorf("%v: %v", name, err)
}
out = append(out, conv.ConvertFont(fnt))
} }
return out, nil win, err := ui.NewWindow(driver, "View Menu: "+*menuName, *winX, *winY)
}
func (e *env) run() {
win, err := ui.NewWindow("View Menu: " + *menuFile)
if err != nil { if err != nil {
log.Fatal("Couldn't create window: %v", err) log.Fatal("Couldn't create window: %v", err)
} }
pWin := win.PixelWindow // Change the active dialogue
state := &state{env: e} dialogues := driver.Dialogues()
if len(dialogues) > 0 {
// For now, just try to display the various objects dlg := &dlg{
// left + right to change object, up + down to change frame driver: driver,
win.Run(func() { list: dialogues,
oldState := *state
state = state.runStep(pWin)
if oldState != *state || oldState.step == 0 {
state.present(pWin)
} }
win.OnKeyUp(ebiten.KeyLeft, dlg.changeDialogue(-1))
state.step += 1 win.OnKeyUp(ebiten.KeyRight, dlg.changeDialogue(+1))
}) for i, dialogue := range dlg.list {
} log.Printf("Dialogue %v: %v", i, dialogue)
func (s *state) runStep(pWin *pixelgl.Window) *state {
newState := *s
newState.winPos = pWin.GetPos()
newState.winBounds = pWin.Bounds()
newState.handleKeys(pWin)
return &newState
}
const (
origX = 640.0
origY = 480.0
)
func (s *state) present(pWin *pixelgl.Window) {
pWin.Clear(colornames.Black)
s.env.batch.Clear()
// The menus expect to be drawn to a 640x480 screen. We need to scale and
// project that so it fills the window appropriately. This is a combination
// of translate + zoom
winSize := pWin.Bounds().Max
scaleFactor := pixel.Vec{winSize.X / origX, winSize.Y / origY}
cam := pixel.IM
cam = cam.ScaledXY(pixel.ZV, pixel.Vec{1.0, -1.0}) // invert the Y axis
cam = cam.Moved(pixel.Vec{origX / 2, origY / 2})
cam = cam.ScaledXY(pixel.ZV, scaleFactor)
s.cam = cam
s.env.batch.SetMatrix(cam)
textCanvas := pixelgl.NewCanvas(pWin.Bounds())
textCanvas.SetMatrix(pixel.IM.ScaledXY(pixel.ZV, scaleFactor))
for _, record := range s.env.menu.Records {
s.drawRecord(record, s.env.batch, textCanvas)
}
s.env.batch.Draw(pWin)
textCanvas.Draw(pWin, pixel.IM)
}
func (s *state) drawRecord(record *menus.Record, target, textTarget pixel.Target) {
// Draw this record if it's valid to do so. FIXME: lots to learn
if len(record.SpriteId) >= 0 {
spriteId := record.SpriteId[0]
x := float64(record.X)
y := float64(record.Y)
// Theory: we either give spriteid, or y,x,spriteId
if len(record.SpriteId) == 3 {
x = x + float64(record.SpriteId[1])
y = y + float64(record.SpriteId[0]*2) // FIXME: *2 works, no idea
spriteId = record.SpriteId[2]
}
// FIXME: some here are set at -1. Presume that means don't draw.
if spriteId < 0 {
goto out
}
// FIXME: some are set at -1, -1. No idea why. Origin?
if x < 0.0 {
x = 0.0
}
if y < 0.0 {
y = 0.0
}
log.Printf(
"Drawing id=%v type=%v spriteid=%v x=%v y=%v desc=%q parent=%p",
record.Id, record.Type, spriteId, record.X, record.Y, record.Desc, record.Parent,
)
// FIXME: Need to handle multiple objects
offset := pixel.V(x, y)
obj := s.env.objects[0]
sprite := obj.Sprites[spriteId]
sprite.Spr.Draw(target, pixel.IM.Moved(offset))
// FIXME: we probably shouldn't draw everything?
// FIXME: handle multiple fonts
if len(s.env.fonts) > 0 && record.Desc != "" {
s.env.fonts[0].Output(textTarget, pixel.IM.Moved(offset), record.Desc)
} }
} }
out:
// Draw all children of this record if err := win.Run(); err != nil {
for _, child := range record.Children { log.Fatal(err)
s.drawRecord(child, target, textTarget)
} }
} }
func (s *state) handleKeys(pWin *pixelgl.Window) { func (d *dlg) changeDialogue(by int) func() {
if pWin.JustPressed(pixelgl.MouseButton1) { return func() {
log.Printf("cam: %#v", s.cam) 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
}
pos := s.cam.Unproject(pWin.MousePosition()) locator := d.list[newPos]
log.Printf("X=%v Y=%v", pos.X, pos.Y) log.Printf("Showing dialogue %v: %q", newPos, locator)
d.driver.ShowDialogue(locator)
d.pos = newPos
} }
/*
if pWin.JustPressed(pixelgl.KeyLeft) {
if s.objIdx > 0 {
s.objIdx -= 1
s.spriteIdx = 0
}
}
if pWin.JustPressed(pixelgl.KeyRight) {
if s.objIdx < s.env.set.Count()-1 {
s.objIdx += 1
s.spriteIdx = 0
}
}
if pWin.JustPressed(pixelgl.KeyDown) {
if s.spriteIdx > 0 {
s.spriteIdx -= 1
}
}
if pWin.JustPressed(pixelgl.KeyUp) {
if s.spriteIdx < len(s.curObject().Sprites)-1 {
s.spriteIdx += 1
}
}
// Zoom in and out with the mouse wheel
s.zoom *= math.Pow(1.2, pWin.MouseScroll().Y)
*/
} }

View File

@@ -2,41 +2,45 @@ package main
import ( import (
"flag" "flag"
"image"
"image/color"
"log" "log"
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/faiface/pixel" "github.com/hajimehoshi/ebiten"
"github.com/faiface/pixel/imdraw"
"github.com/faiface/pixel/pixelgl"
"golang.org/x/image/colornames"
"ur.gs/ordoor/internal/maps" "code.ur.gs/lupine/ordoor/internal/maps"
"ur.gs/ordoor/internal/sets" "code.ur.gs/lupine/ordoor/internal/sets"
"ur.gs/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") gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
mapFile = flag.String("map", "", "Prefix path to a .map file, e.g. ./orig/Maps/Chapter01.MAP") mapFile = flag.String("map", "", "Prefix path to a .map file, e.g. ./orig/Maps/Chapter01.MAP")
txtFile = flag.String("txt", "", "Prefix path to a .txt file, e.g. ./orig/Maps/Chapter01.txt") txtFile = flag.String("txt", "", "Prefix path to a .txt file, e.g. ./orig/Maps/Chapter01.txt")
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 { type env struct {
gameMap *maps.GameMap gameMap *maps.GameMap
set *sets.MapSet set *sets.MapSet
state state
lastState state
step int
} }
type runState struct { type state struct {
env *env
autoUpdate bool autoUpdate bool
started time.Time started time.Time
cam pixel.Matrix origin image.Point
camPos pixel.Vec
zoom float64 zoom float64
@@ -64,41 +68,138 @@ func main() {
log.Fatalf("Couldn't load set file %s: %v", setFile, err) log.Fatalf("Couldn't load set file %s: %v", setFile, err)
} }
env := &env{gameMap: gameMap, set: mapSet} state := state{
autoUpdate: true,
zoom: 8.0,
}
env := &env{gameMap: gameMap, set: mapSet, state: state, lastState: state}
// The main thread now belongs to pixelgl win, err := ui.NewWindow(env, "View Map "+*mapFile, *winX, *winY)
pixelgl.Run(env.run)
}
func (e *env) run() {
win, err := ui.NewWindow("View Map " + *mapFile)
if err != nil { if err != nil {
log.Fatal("Couldn't create window: %v", err) log.Fatal("Couldn't create window: %v", err)
} }
pWin := win.PixelWindow win.OnKeyUp(ebiten.KeyEnter, env.toggleAutoUpdate)
state := &runState{
env: e, win.OnKeyUp(ebiten.KeyLeft, env.changeOrigin(+4, +0))
autoUpdate: true, win.OnKeyUp(ebiten.KeyRight, env.changeOrigin(-4, +0))
camPos: pixel.V(0, float64(-pWin.Bounds().Size().Y)), win.OnKeyUp(ebiten.KeyUp, env.changeOrigin(+0, +4))
zoom: 8.0, win.OnKeyUp(ebiten.KeyDown, env.changeOrigin(+0, -4))
win.OnKeyUp(ebiten.KeyMinus, env.changeCellIdx(-1))
win.OnKeyUp(ebiten.KeyEqual, env.changeCellIdx(+1))
for i := 0; i <= 6; i++ {
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i))
} }
win.Run(func() { win.OnMouseWheel(env.changeZoom)
oldState := *state
state = runStep(pWin, state)
if oldState != *state { if err := win.Run(); err != nil {
log.Printf("z=%d cellIdx=%d", state.zIdx, state.cellIdx) log.Fatal(err)
present(pWin, state) }
}
func (e *env) setZIdx(to int) func() {
return func() {
e.state.zIdx = to
}
}
// Enable / disable auto-update
func (e *env) toggleAutoUpdate() {
e.state.autoUpdate = !e.state.autoUpdate
if e.state.autoUpdate {
e.state.started = time.Now()
}
}
func (e *env) changeOrigin(byX, byY int) func() {
return func() {
e.state.origin.X += byX
e.state.origin.Y += byY
}
}
func (e *env) changeCellIdx(by int) func() {
return func() {
e.state.cellIdx += by
if e.state.cellIdx < 0 {
e.state.cellIdx = 0
} }
})
if e.state.cellIdx > maps.CellSize-1 {
e.state.cellIdx = maps.CellSize - 1
}
}
}
func (e *env) changeZoom(_, y float64) {
// Zoom in and out with the mouse wheel
e.state.zoom *= math.Pow(1.2, y)
}
func (e *env) Update(screenX, screenY int) error {
// TODO: show details of clicked-on cell in terminal
// Automatically cycle every 500ms when auto-update is on
if e.state.autoUpdate && time.Now().Sub(e.state.started) > 500*time.Millisecond {
e.state.cellIdx += 1
// bounds checking
if e.state.cellIdx >= maps.CellSize {
e.state.cellIdx = 0
e.state.zIdx += 1
}
if e.state.zIdx >= maps.MaxHeight {
e.state.zIdx = 0
}
e.state.started = time.Now()
}
if e.step == 0 || e.lastState != e.state {
log.Printf("z=%d cellIdx=%d origin=%#v", e.state.zIdx, e.state.cellIdx, e.state.origin)
}
e.step += 1
e.lastState = e.state
return nil
}
func (e *env) Draw(screen *ebiten.Image) error {
gameMap := e.gameMap
rect := gameMap.Rect()
imd, err := ebiten.NewImage(rect.Dx(), rect.Dy(), ebiten.FilterDefault)
if err != nil {
return err
}
for y := int(rect.Min.Y); y < int(rect.Max.Y); y++ {
for x := int(rect.Min.X); x < int(rect.Max.X); x++ {
cell := gameMap.Cells.At(x, y, int(e.state.zIdx))
imd.Set(x, y, makeColour(&cell, e.state.cellIdx))
}
}
// TODO: draw a boundary around the minimap
cam := ebiten.GeoM{}
cam.Translate(float64(e.state.origin.X), float64(e.state.origin.Y)) // Move to origin
cam.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
cam.Rotate(0.785) // Apply isometric angle
return screen.DrawImage(imd, &ebiten.DrawImageOptions{GeoM: cam})
} }
// Converts pixel coordinates to cell coordinates // Converts pixel coordinates to cell coordinates
func vecToCell(vec pixel.Vec) (int, int) { func vecToCell(p image.Point) (int, int) {
x := int(vec.X) x := int(p.X)
y := int(vec.Y) y := int(p.Y)
if x < 0 { if x < 0 {
x = 0 x = 0
@@ -119,56 +220,13 @@ func vecToCell(vec pixel.Vec) (int, int) {
return x, y return x, y
} }
func cellToVec(x, y int) pixel.Rect { func cellToVec(x, y int) image.Rectangle {
min := pixel.Vec{X: float64(x), Y: float64(y)} min := image.Point{X: x, Y: y}
max := pixel.Vec{X: min.X + 1, Y: min.Y + 1} max := image.Point{X: min.X + 1, Y: min.Y + 1}
return pixel.Rect{Min: min, Max: max} return image.Rect(min.X, min.Y, max.X, max.Y)
} }
func present(win *pixelgl.Window, state *runState) { func makeColour(cell *maps.Cell, colIdx int) color.RGBA {
gameMap := state.env.gameMap
imd := imdraw.New(nil)
for y := gameMap.MinLength; y < gameMap.MaxLength; y++ {
for x := gameMap.MinWidth; x < gameMap.MaxWidth; x++ {
rect := cellToVec(int(x), int(y))
cell := gameMap.Cells.At(int(x), int(y), int(state.zIdx))
// TODO: represent the state of the cell *sensibly*, using colour.
// Need to understand the contents better first, so for now optimize
// for exploration
imd.Color = makeColour(&cell, state.cellIdx)
imd.Push(rect.Min, rect.Max)
imd.Rectangle(0.0)
}
}
// Draw the boundary
rect := pixel.R(
float64(gameMap.MinWidth)-0.5, float64(gameMap.MinLength)-0.5,
float64(gameMap.MaxWidth)+0.5, float64(gameMap.MaxLength)+0.5,
)
imd.Color = pixel.RGB(255, 0, 0)
imd.EndShape = imdraw.SharpEndShape
imd.Push(rect.Min, rect.Max)
imd.Rectangle(1.0)
center := win.Bounds().Center()
cam := pixel.IM
cam = cam.ScaledXY(pixel.ZV, pixel.Vec{1.0, -1.0}) // invert the Y axis
cam = cam.Scaled(pixel.ZV, state.zoom) // apply current zoom factor
cam = cam.Moved(center.Sub(state.camPos)) // Make it central
cam = cam.Rotated(center.Sub(state.camPos), -0.785) // Apply isometric angle
state.cam = cam
win.SetMatrix(state.cam)
win.Clear(colornames.Black)
imd.Draw(win)
}
func makeColour(cell *maps.Cell, colIdx int) pixel.RGBA {
var scale func(float64) float64 var scale func(float64) float64
mult := func(factor float64) func(float64) float64 { mult := func(factor float64) func(float64) float64 {
@@ -176,6 +234,7 @@ func makeColour(cell *maps.Cell, colIdx int) pixel.RGBA {
} }
// Different columns do better with different levels of greyscale. // Different columns do better with different levels of greyscale.
// FIXME: this may not be translated correctly from pixel
switch colIdx { switch colIdx {
case 0: case 0:
@@ -202,94 +261,6 @@ func makeColour(cell *maps.Cell, colIdx int) pixel.RGBA {
scale = mult(0.01) // close to maximum resolution, low-value fields will be lost scale = mult(0.01) // close to maximum resolution, low-value fields will be lost
} }
col := scale(float64(cell.At(colIdx))) col := uint8(scale(float64(cell.At(colIdx))))
return pixel.RGB(col, col, col) return color.RGBA{col, col, col, 255}
}
func runStep(win *pixelgl.Window, state *runState) *runState {
nextState := *state
// Enable / disable auto-update with the enter key
if win.JustPressed(pixelgl.KeyEnter) {
nextState.autoUpdate = !state.autoUpdate
log.Printf("autoUpdate=%v", nextState.autoUpdate)
if nextState.autoUpdate {
nextState.started = time.Now()
}
}
// Automatically cycle every second when auto-update is on
if nextState.autoUpdate && time.Now().Sub(state.started) > 500*time.Millisecond {
nextState.cellIdx = nextState.cellIdx + 1
if nextState.cellIdx >= maps.CellSize {
nextState.cellIdx = 0
nextState.zIdx = nextState.zIdx + 1
}
if nextState.zIdx >= maps.MaxHeight {
nextState.zIdx = 0
}
nextState.started = time.Now()
}
if win.Pressed(pixelgl.KeyLeft) {
nextState.camPos.X -= 4
}
if win.Pressed(pixelgl.KeyRight) {
nextState.camPos.X += 4
}
if win.Pressed(pixelgl.KeyDown) {
nextState.camPos.Y -= 4
}
if win.Pressed(pixelgl.KeyUp) {
nextState.camPos.Y += 4
}
for i := 0; i <= 6; i++ {
if win.JustPressed(pixelgl.Key1 + pixelgl.Button(i)) {
nextState.zIdx = i
}
}
// Decrease the cell index
if win.JustPressed(pixelgl.KeyMinus) {
if nextState.cellIdx > 0 {
nextState.cellIdx -= 1
}
}
// Increase the cell index
if win.JustPressed(pixelgl.KeyEqual) {
if nextState.cellIdx < maps.CellSize-1 {
nextState.cellIdx += 1
}
}
// Show details of clicked-on cell in termal
if win.JustPressed(pixelgl.MouseButtonLeft) {
vec := state.cam.Unproject(win.MousePosition())
x, y := vecToCell(vec)
log.Printf("%#v -> %d,%d", vec, x, y)
cell := state.env.gameMap.Cells.At(x, y, state.zIdx)
log.Printf(
"x=%d y=%d z=%d SurfaceTile=%d (%s) SurfaceSprite=%d SquadRelated=%d",
x, y, state.zIdx,
cell.Surface.Index(), state.env.set.SurfacePalette[int(cell.Surface.Index())], cell.Surface.Sprite(),
cell.SquadRelated,
)
log.Printf("CellIdx%d=%d. Full cell data: %#v", state.cellIdx, cell.At(state.cellIdx), cell)
}
// Zoom in and out with the mouse wheel
nextState.zoom *= math.Pow(1.2, win.MouseScroll().Y)
if nextState.zoom != state.zoom {
log.Printf("zoom=%.2f", nextState.zoom)
}
return &nextState
} }

View File

@@ -2,133 +2,165 @@ package main
import ( import (
"flag" "flag"
"image"
"log" "log"
"math" "math"
"os" "os"
"path/filepath"
"github.com/faiface/pixel" "github.com/hajimehoshi/ebiten"
"github.com/faiface/pixel/pixelgl"
"golang.org/x/image/colornames"
"ur.gs/ordoor/internal/conv" "code.ur.gs/lupine/ordoor/internal/assetstore"
"ur.gs/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/config"
"ur.gs/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", "", "Path to a .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ") engine = flag.String("engine", "", "Override engine to use")
objFile = flag.String("obj-file", "", "Path of an .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ")
objName = flag.String("obj-name", "", "Name of an .obj file, e.g. TZEENTCH")
sprIdx = flag.Int("spr-idx", 0, "Sprite index to start at")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
) )
type env struct { type env struct {
obj *conv.Object obj *assetstore.Object
spr *assetstore.Sprite
step int
state state
lastState state
} }
type state struct { type state struct {
env *env
step int
spriteIdx int spriteIdx int
zoom float64 zoom float64
origin image.Point
cam pixel.Matrix
camPos pixel.Vec
} }
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || *objFile == "" { if *configFile == "" || (*objName == "" && *objFile == "") {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
rawObj, err := data.LoadObject(*objFile) cfg, err := config.Load(*configFile, *engine)
if err != nil { if err != nil {
log.Fatalf("Failed to load %s: %v", *objFile, err) log.Fatalf("Failed to load config: %v", err)
} }
obj := conv.ConvertObject(rawObj, filepath.Base(*objFile))
env := &env{obj: obj} assets, err := assetstore.New(cfg.DefaultEngine())
// The main thread now belongs to pixelgl
pixelgl.Run(env.run)
}
func (e *env) run() {
win, err := ui.NewWindow("View Object: " + *objFile)
if err != nil { if err != nil {
log.Fatal("Couldn't create window: %v", err) log.Fatalf("Failed to set up asset store: %v", err)
} }
pWin := win.PixelWindow var obj *assetstore.Object
state := &state{ if *objName != "" {
env: e, obj, err = assets.Object(*objName)
camPos: pixel.V(0, float64(-pWin.Bounds().Size().Y)), } else {
zoom: 8.0, obj, err = assets.ObjectByPath(*objFile)
}
if err != nil {
log.Fatalf("Failed to load %s%s: %v", *objName, *objFile, err)
} }
// For now, just try to display the various objects state := state{
// left + right to change object, up + down to change frame zoom: 6.0,
win.Run(func() { origin: image.Point{0, 0},
oldState := *state spriteIdx: *sprIdx,
state = state.runStep(pWin) }
if oldState != *state || oldState.step == 0 { env := &env{
log.Printf( obj: obj,
"new state: numSprites=%d sprite=%d zoom=%.2f", state: state,
len(state.env.obj.Sprites), lastState: state,
state.spriteIdx, }
state.zoom,
)
state.present(pWin)
}
state.step += 1 win, err := ui.NewWindow(env, "View Object: "+*objName, *winX, *winY)
}) if err != nil {
log.Fatal(err)
}
win.OnKeyUp(ebiten.KeyMinus, env.changeSprite(-1))
win.OnKeyUp(ebiten.KeyEqual, env.changeSprite(+1))
win.OnKeyUp(ebiten.KeyLeft, env.changeOrigin(+4, +0))
win.OnKeyUp(ebiten.KeyRight, env.changeOrigin(-4, +0))
win.OnKeyUp(ebiten.KeyUp, env.changeOrigin(+0, +4))
win.OnKeyUp(ebiten.KeyDown, env.changeOrigin(+0, -4))
win.OnMouseWheel(env.changeZoom)
// The main thread now belongs to ebiten
if err := win.Run(); err != nil {
log.Fatal(err)
}
} }
func (s *state) runStep(pWin *pixelgl.Window) *state { func (e *env) Update(screenX, screenY int) error {
newState := *s if e.step == 0 || e.lastState != e.state {
newState.handleKeys(pWin) sprite, err := e.obj.Sprite(e.state.spriteIdx)
if err != nil {
return &newState return err
}
func (s *state) present(pWin *pixelgl.Window) {
obj := s.env.obj
sprite := obj.Sprites[s.spriteIdx]
center := pWin.Bounds().Center()
cam := pixel.IM
cam = cam.ScaledXY(center, pixel.Vec{1.0, -1.0}) // invert the Y axis
cam = cam.Scaled(center, s.zoom) // apply current zoom factor
//cam = cam.Moved(center.Sub(s.camPos)) // Make it central
//cam = cam.Rotated(center, -0.785) // Apply isometric angle
s.cam = cam
pWin.SetMatrix(s.cam)
pWin.Clear(colornames.Black)
pixel.NewSprite(sprite.Pic, sprite.Pic.Bounds()).Draw(pWin, pixel.IM.Moved(center))
}
func (s *state) handleKeys(pWin *pixelgl.Window) {
if pWin.JustPressed(pixelgl.KeyMinus) {
if s.spriteIdx > 0 {
s.spriteIdx -= 1
} }
e.spr = sprite
log.Printf(
"new state: sprite=%d/%d bounds=%+#v zoom=%.2f, origin=%+v",
e.state.spriteIdx,
e.obj.NumSprites,
e.spr.Rect,
e.state.zoom,
e.state.origin,
)
} }
if pWin.JustPressed(pixelgl.KeyEqual) { // This should be the final action
if s.spriteIdx < len(s.env.obj.Sprites)-1 { e.step += 1
s.spriteIdx += 1 e.lastState = e.state
}
return nil
}
func (e *env) Draw(screen *ebiten.Image) error {
sprite, err := e.obj.Sprite(e.state.spriteIdx)
if err != nil {
return err
} }
cam := ebiten.GeoM{}
cam.Translate(float64(e.state.origin.X), float64(e.state.origin.Y)) // Move to origin
cam.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
return screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: cam})
}
func (e *env) changeSprite(by int) func() {
return func() {
e.state.spriteIdx += by
if e.state.spriteIdx < 0 {
e.state.spriteIdx = 0
}
if e.state.spriteIdx > e.obj.NumSprites-1 {
e.state.spriteIdx = e.obj.NumSprites - 1
}
}
}
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 // Zoom in and out with the mouse wheel
s.zoom *= math.Pow(1.2, pWin.MouseScroll().Y) e.state.zoom *= math.Pow(1.2, y)
} }

View File

@@ -2,173 +2,183 @@ package main
import ( import (
"flag" "flag"
"image"
"log" "log"
"math" "math"
"os" "os"
"path/filepath"
"github.com/faiface/pixel" "github.com/hajimehoshi/ebiten"
"github.com/faiface/pixel/pixelgl"
"golang.org/x/image/colornames"
"ur.gs/ordoor/internal/conv" "code.ur.gs/lupine/ordoor/internal/assetstore"
"ur.gs/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/config"
"ur.gs/ordoor/internal/sets" "code.ur.gs/lupine/ordoor/internal/ui"
"ur.gs/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")
setFile = flag.String("set", "", "Path to a .set file, e.g. ./orig/Sets/map01.set") engine = flag.String("engine", "", "Override engine to use")
setName = flag.String("set", "", "Name of a set, e.g., map01")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
) )
type env struct { type env struct {
set *sets.MapSet set *assetstore.Set
objects map[string]*conv.Object step int
batch *pixel.Batch state state
lastState state
} }
type state struct { type state struct {
env *env
step int
objIdx int objIdx int
spriteIdx int spriteIdx int
zoom float64 zoom float64
origin image.Point
cam pixel.Matrix
camPos pixel.Vec
} }
func main() { func main() {
flag.Parse() flag.Parse()
if *gamePath == "" || *setFile == "" { if *configFile == "" || *setName == "" {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
mapSet, err := sets.LoadSet(*setFile) cfg, err := config.Load(*configFile, *engine)
if err != nil { if err != nil {
log.Fatalf("Couldn't load set file %s: %v", *setFile, err) log.Fatalf("Failed to load config: %v", err)
} }
rawObjs := []*data.Object{} assets, err := assetstore.New(cfg.DefaultEngine())
for _, name := range mapSet.Palette { if err != nil {
objFile := filepath.Join(*gamePath, "Obj", name+".obj") log.Fatal(err)
obj, err := data.LoadObject(objFile)
if err != nil {
log.Fatalf("Failed to load %s: %v", name, err)
}
obj.Name = name
rawObjs = append(rawObjs, obj)
} }
objects, spritesheet := conv.ConvertObjects(rawObjs) set, err := assets.Set(*setName)
batch := pixel.NewBatch(&pixel.TrianglesData{}, spritesheet) if err != nil {
log.Fatalf("Couldn't load set %s: %v", *setName, err)
}
env := &env{objects: conv.MapByName(objects), set: mapSet, batch: batch} state := state{zoom: 8.0}
env := &env{
set: set,
state: state,
lastState: state,
}
// The main thread now belongs to pixelgl win, err := ui.NewWindow(env, "View Set: "+*setName, *winX, *winY)
pixelgl.Run(env.run)
}
func (e *env) run() {
win, err := ui.NewWindow("View Set: " + *setFile)
if err != nil { if err != nil {
log.Fatal("Couldn't create window: %v", err) log.Fatal("Couldn't create window: %v", err)
} }
pWin := win.PixelWindow win.OnKeyUp(ebiten.KeyLeft, env.changeObjIdx(-1))
state := &state{ win.OnKeyUp(ebiten.KeyRight, env.changeObjIdx(+1))
env: e,
camPos: pixel.V(0, float64(-pWin.Bounds().Size().Y)), win.OnKeyUp(ebiten.KeyUp, env.changeSpriteIdx(+1))
zoom: 8.0, win.OnKeyUp(ebiten.KeyDown, env.changeSpriteIdx(-1))
win.OnMouseWheel(env.changeZoom)
// Main thread now belongs to ebiten
if err := win.Run(); err != nil {
log.Fatal(err)
} }
// For now, just try to display the various objects
// left + right to change object, up + down to change frame
win.Run(func() {
oldState := *state
state = state.runStep(pWin)
if oldState != *state || oldState.step == 0 {
log.Printf(
"new state: numObj=%d object=%d (%s) numFrames=%d sprite=%d zoom=%.2f",
state.env.set.Count(),
state.objIdx,
state.env.set.Palette[state.objIdx], // FIXME: palette is a confusing name
len(state.curObject().Sprites),
state.spriteIdx,
state.zoom,
)
state.present(pWin)
}
state.step += 1
})
} }
func (s *state) runStep(pWin *pixelgl.Window) *state { func (e *env) Update(screenX, screenY int) error {
newState := *s curObj, err := e.curObject()
newState.handleKeys(pWin) if err != nil {
return err
}
return &newState if e.step == 0 || e.lastState != e.state {
log.Printf(
"new state: object=%d/%d (%s) numFrames=%d sprite=%d zoom=%.2f",
e.state.objIdx,
e.set.NumObjects,
curObj.Name,
curObj.NumSprites,
e.state.spriteIdx,
e.state.zoom,
)
}
e.step += 1
e.lastState = e.state
return nil
} }
func (s *state) present(pWin *pixelgl.Window) { func (e *env) Draw(screen *ebiten.Image) error {
obj := s.curObject() sprite, err := e.curSprite()
sprite := obj.Sprites[s.spriteIdx] if err != nil {
return err
}
pWin.Clear(colornames.Black) cam := ebiten.GeoM{}
s.env.batch.Clear() cam.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
center := pWin.Bounds().Center() // TODO: centre the image
cam := pixel.IM return screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: cam})
cam = cam.ScaledXY(center, pixel.Vec{1.0, -1.0}) // invert the Y axis
cam = cam.Scaled(center, s.zoom) // apply current zoom factor
s.cam = cam
pWin.SetMatrix(s.cam)
sprite.Spr.Draw(s.env.batch, pixel.IM.Moved(center))
s.env.batch.Draw(pWin)
} }
func (s *state) handleKeys(pWin *pixelgl.Window) { func (e *env) changeObjIdx(by int) func() {
if pWin.JustPressed(pixelgl.KeyLeft) { return func() {
if s.objIdx > 0 { old := e.state.objIdx
s.objIdx -= 1 e.state.objIdx += by
s.spriteIdx = 0
if e.state.objIdx < 0 {
e.state.objIdx = 0
}
if e.state.objIdx > e.set.NumObjects-1 {
e.state.objIdx = e.set.NumObjects - 1
}
// reset sprite index when object changes
if old != e.state.objIdx {
e.state.spriteIdx = 0
} }
} }
}
if pWin.JustPressed(pixelgl.KeyRight) { func (e *env) changeSpriteIdx(by int) func() {
if s.objIdx < s.env.set.Count()-1 { return func() {
s.objIdx += 1 obj, err := e.curObject()
s.spriteIdx = 0 if err != nil {
} log.Printf("Encountered %v trying to change sprite index", err)
} return
}
if pWin.JustPressed(pixelgl.KeyDown) {
if s.spriteIdx > 0 { e.state.spriteIdx += by
s.spriteIdx -= 1 if e.state.spriteIdx < 0 {
} e.state.spriteIdx = 0
} }
if pWin.JustPressed(pixelgl.KeyUp) { if e.state.spriteIdx > obj.NumSprites-1 {
if s.spriteIdx < len(s.curObject().Sprites)-1 { e.state.spriteIdx = obj.NumSprites - 1
s.spriteIdx += 1
} }
} }
}
func (e *env) changeZoom(_, y float64) {
// Zoom in and out with the mouse wheel // Zoom in and out with the mouse wheel
s.zoom *= math.Pow(1.2, pWin.MouseScroll().Y) e.state.zoom *= math.Pow(1.2, y)
} }
func (s *state) curObject() *conv.Object { func (e *env) curObject() (*assetstore.Object, error) {
name := s.env.set.Palette[s.objIdx] return e.set.Object(e.state.objIdx)
return s.env.objects[name] }
func (e *env) curSprite() (*assetstore.Sprite, error) {
obj, err := e.curObject()
if err != nil {
return nil, err
}
return obj.Sprite(e.state.spriteIdx)
} }

View File

@@ -1,21 +0,0 @@
package main
import (
"log"
"os"
"ur.gs/ordoor/internal/wh40k"
)
func main() {
configFile := "config.toml"
if len(os.Args) == 2 {
configFile = os.Args[1]
}
if err := wh40k.Run(configFile); err != nil {
log.Fatalf(err.Error())
}
os.Exit(0)
}

View File

@@ -1,7 +1,35 @@
[wh40k] 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 = [ default_engine = "ordoor"
"mpv",
"--no-config", "--keep-open=no", "--force-window=no", "--no-border", [engines.geas] # Wages of War -> Gifts of Peace -> Geas
"--no-osc", "--fullscreen", "--no-input-default-bindings" data_dir = "./WoW-CD"
] palette = "WagesOfWar"
[engines.ordoor] # Chaos Gate -> Order Door -> Ordoor
data_dir = "./CG"
palette = "ChaosGate"
[engines.baps] # Soldiers At War -> Boys at Play -> Baps
data_dir = "./SaW"
palette = "SoldiersAtWar"
[engines.sl] # Squad Leader -> ??? -> ???
data_dir = "./SL"
palette = "ChaosGate" # may not be relevant?
[options]
play_movies = true
animations = true
play_music = true
combat_voices = true
show_grid = false
show_paths = false
point_saving = false
auto_cut_level = false
x_resolution = 1280
y_resolution = 1024
music_volume = 100
sfx_volume = 100
unit_speed = 100
animation_speed = 100

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?

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

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,44 +162,121 @@ 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
The X-CORD and Y-CORD values would seem to be related, to this, but they are
universally set to 0 or -1.
Far more important are the XOffset and YOffset values for each sprite in the
associated .obj files. Taking these into account is enough to draw `Options.mnu`
successfully, for instance:
![](img/Options.mnu.png)
However, it's *not* sufficient to put all the items for `MainGame.mnu` in the
right place.
## Animation
This seems to be done by choosing a different sprite to draw every N ticks. They
are laid out sequentially, but I don't yet know how to animate them. It's likely
to be the same approach as used for other obj files.
Looking at Main.mnu, it points at the object fail Main.obj. This has 118
sprites, which can be described as follows:
| Start | Count | Desc |
| ------ | ----- | ---- |
| 0 | 1 | Background image |
| 1 | 3 | New game button: base, pressed, disabled |
| 4 | 3 | Load game button: base, pressed, disabled |
| 7 | 3 | Multiplayer button: base, pressed, disabled |
| 10 | 3 | Settings button: base, pressed, disabled |
| 13 | 3 | Quit button: base, pressed, disabled |
| 16 | 20 | New game button: 20 animation frames |
| 36 | 20 | Load game button: 20 animation frames |
| 56 | 20 | Multiplayer button: 20 animation frames |
| 76 | 20 | Settings button: 20 animation frames |
| 96 | 20 | Quit button: 20 animation frames |
| 116 | 1 | Section of background ("Menu title") |
| 117 | 1 | Version hotspot |
So we have 5 buttons with very similar characteristics, but at different sprite
offsets, and two distinct ranges per button, plus some others. Here's some
attributes plucked from `Main.mnu`:
| Name | (SUB)MENUTYPE | "Active" | "SPRITEID" | "DRAW TYPE" | "SHARE" |
| ---------- | ------------- | -------- | ---------- | ----------- | ------- |
| Background | 1 | 1 | 0 | 0 | -1 |
| Start menu | 1 | 1 | -1 | 0 | -1 |
| New game | 228 | 1,0 | 16,-1,1 | 20 | 1 |
| Load game | 228 | 1,0 | 36,-1,4 | 20 | 4 |
| MP game | 228 | 1,0 | 56,-1,7 | 20 | 7 |
| Options | 228 | 1,0 | 76,-1,10 | 20 | 10 |
| Quit | 228 | 1,0 | 96,-1,13 | 20 | 13 |
| Menu title | 61 | 1 | -1 | 0 | 116 |
| V hotspot | 61 | 1 | -1 | 0 | 117 |
The buttons, menu title and version hotspot are submenus of the start menu.
### `ACTIVE`
There are only 4 values seen across all menus: `0`, `1`, `1,0`, `102` and `1,1`.
Perhaps this represents possible states?
### Sprite selection
For the background (`MENUTYPE: 1`), this points simply at the sprite index in
the object file. For the start menu, it's `-1` (no sprite, I assume). For the
menu title and version hotspot (`MENUTYPE: 61`, it's `-1` too.
For the buttons, it's a list pointing to the start of the 20 animated frames,
`-1`, then the start of the 3 static frames.
`DRAW TYPE` is the number of animated frames. We only use the animated frames
when the button is focused. `SHARE` repeats the start of the static frames, and
is the only place they're found for the menu title and version hotspot.

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
@@ -202,14 +128,12 @@ in the CENTER position. Interesting.
| Offset | Purpose | | Offset | Purpose |
| ------ | ------- | | ------ | ------- |
| 0x0000 | ? | 0x0000 | x,y offset (16 bits each) |
| 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) |
We still don't know what the first 32 bits are all about.
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
@@ -222,8 +146,9 @@ for instance.
Loading a constructed `palette.obj` containing 1x63 pixels, with 0x0000 for the Loading a constructed `palette.obj` containing 1x63 pixels, with 0x0000 for the
first 32 bits, caused WH40K_TD.exe to render the pixels far to the left and above first 32 bits, caused WH40K_TD.exe to render the pixels far to the left and above
the cell the palette objects were being placed into. This suggests they the cell the palette objects were being placed into. Menus have a `.obj` file
represent an x,y offset to draw to. Need to experiment more. associated with them too, and the elements for `Main.mnu` draw in the correct
places if we treat it as such.
Considering sprites with a 1,1 x,y. Considering sprites with a 1,1 x,y.
@@ -513,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.

24
go.mod
View File

@@ -1,15 +1,19 @@
module ur.gs/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/faiface/glhf v0.0.0-20181018222622-82a6317ac380 // indirect github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065
github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 // indirect github.com/hajimehoshi/ebiten v1.11.1
github.com/faiface/pixel v0.8.0 github.com/jfreymuth/oggvorbis v1.0.1 // indirect
github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 // indirect github.com/kr/text v0.2.0 // indirect
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/go-gl/mathgl v0.0.0-20190713194549-592312d8590a // indirect github.com/samuel/go-pcx v0.0.0-20180426214139-d9c017170db4
github.com/pkg/errors v0.8.1 // indirect github.com/stretchr/testify v1.5.1
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a 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
) )

125
go.sum
View File

@@ -1,20 +1,111 @@
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
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/faiface/glhf v0.0.0-20181018222622-82a6317ac380 h1:FvZ0mIGh6b3kOITxUnxS3tLZMh7yEoHo75v3/AgUqg0= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/faiface/glhf v0.0.0-20181018222622-82a6317ac380/go.mod h1:zqnPFFIuYFFxl7uH2gYByJwIVKG7fRqlqQCbzAnHs9g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 h1:baVdMKlASEHrj19iqjARrPbaRisD7EuZEVJj6ZMLl1Q= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3/go.mod h1:VEPNJUlxl5KdWjDvz6Q1l+rJlxF2i6xqDeGuGAxa87M= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/faiface/pixel v0.8.0 h1:phOHW6ixfMAKRamjnvhI6FFI2VRyPEq7+LmmkDGXB/4= github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065 h1:7QVNyw2v9R1qOvbe9vfeVJWWKCSnd2Ap+8l8/CtG9LM=
github.com/faiface/pixel v0.8.0/go.mod h1:CEUU/s9E82Kqp01Boj1O67KnBskqiLghANqvUJGgDAM= github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065/go.mod h1:uN4GbWHfit2ByfOKQ4K6fuLy1/Os2eLynsIrDvjiDgM=
github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 h1:SCYMcCJ89LjRGwEa0tRluNRiMjZHalQZrVrvTbPh+qw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE=
github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/mathgl v0.0.0-20190713194549-592312d8590a h1:yoAEv7yeWqfL/l9A/J5QOndXIJCldv+uuQB1DSNQbS0= github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc=
github.com/go-gl/mathgl v0.0.0-20190713194549-592312d8590a/go.mod h1:yhpkQzEiH9yPyxDUGzkmgScbaBVlhC06qodikEM0ZwQ= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/hajimehoshi/bitmapfont v1.2.0/go.mod h1:h9QrPk6Ktb2neObTlAbma6Ini1xgMjbJ3w7ysmD7IOU=
golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5 h1:hke9UdXY1YPfqjXG1bCSZnoVnfVBw9SzvmlrRn3dL3w=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a h1:gHevYm0pO4QUbwy8Dmdr01R5r1BuKtfYqRqF0h/Cbh0= github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5/go.mod h1:0SLvfr8iI2NxzpNB/olBM+dLN9Ur5a9szG13wOgQ0nQ=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= github.com/hajimehoshi/ebiten v1.11.0 h1:+pIxfzfVgRbHGM7wBAJtgzPiWiZopA7lyIKNQqc9amk=
github.com/hajimehoshi/ebiten v1.11.0/go.mod h1:aDEhx0K9gSpXw3Cxf2hCXDxPSoF8vgjNqKxrZa/B4Dg=
github.com/hajimehoshi/ebiten v1.11.1 h1:7gy2bHBDNtfTh3GlcUAilk3lNWW9fTLaP7iZAodS9F8=
github.com/hajimehoshi/ebiten v1.11.1/go.mod h1:aDEhx0K9gSpXw3Cxf2hCXDxPSoF8vgjNqKxrZa/B4Dg=
github.com/hajimehoshi/go-mp3 v0.2.1 h1:DH4ns3cPv39n3cs8MPcAlWqPeAwLCK8iNgqvg0QBWI8=
github.com/hajimehoshi/go-mp3 v0.2.1/go.mod h1:Rr+2P46iH6PwTPVgSsEwBkon0CK5DxCAeX/Rp65DCTE=
github.com/hajimehoshi/oto v0.3.4/go.mod h1:PgjqsBJff0efqL2nlMJidJgVJywLn6M4y8PI4TfeWfA=
github.com/hajimehoshi/oto v0.5.4 h1:Dn+WcYeF310xqStKm0tnvoruYUV5Sce8+sfUaIvWGkE=
github.com/hajimehoshi/oto v0.5.4/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
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/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/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/samuel/go-pcx v0.0.0-20180426214139-d9c017170db4 h1:Y/KOCu+ZLB730PudefxfsKVjtI0m0RhvFk9a0l4O1+c=
github.com/samuel/go-pcx v0.0.0-20180426214139-d9c017170db4/go.mod h1:qxuIawynlRhuaHowuXvd1xjyFWx87Ro4gkZlKRXtHnQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5 h1:FR+oGxGfbQu1d+jglI3rCkjAjUnhRSZcUxr+DqlDLNo=
golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/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-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-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/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/go.mod h1:p895TfNkDgPEmEQrNiOtIl3j98d/tGU95djDj7NfyjQ=
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007 h1:JxsyO7zPDWn1rBZW8FV5RFwCKqYeXnyaS/VQPLpXu6I=
golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
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.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190909214602-067311248421/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191026034945-b2104f82a97d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

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

View File

@@ -0,0 +1,167 @@
package assetstore
import (
"fmt"
"image/color"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/idx"
"code.ur.gs/lupine/ordoor/internal/palettes"
)
type entryMap map[string]map[string]string
// type AssetStore is responsible for lazily loading game data when it is
// required. Applications shouldn't need to do anything except set one of these
// up, pointing at the game dir root, to access all assets for that game.
//
// Assets should be loaded on-demand to keep memory costs as low as possible.
// Cross-platform differences such as filename case sensitivity are also dealt
// with here.
//
// We assume the directory is read-only. You can run Refresh() if you make a
// change.
//
// To mix assets from different games, either construct a synthetic directory
// or instantiate two separate asset stores.
type AssetStore struct {
RootDir string
Palette color.Palette
// Case-insensitive file lookup.
// {"":{"anim":"Anim", "obj":"Obj", ...}, "anim":{ "warhammer.ani":"WarHammer.ani" }, ...}
entries entryMap
// These members are used to store things we've already loaded
aniObj *Object
cursorObj *Object
cursors map[CursorName]*Cursor
fonts map[string]*Font
generic *data.Generic
idx *idx.Idx
images map[string]*ebiten.Image
maps map[string]*Map
menus map[string]*Menu
objs map[string]*Object
sets map[string]*Set
sounds map[string]*Sound
strings *data.I18n
}
// New returns a new AssetStore
func New(engine *config.Engine) (*AssetStore, error) {
if engine == nil {
return nil, fmt.Errorf("Unconfigured engine passed to assetstore")
}
palette, ok := palettes.Get(engine.Palette)
if !ok {
return nil, fmt.Errorf("Couldn't find palette %q for engine", engine.Palette)
}
store := &AssetStore{
RootDir: engine.DataDir,
Palette: palette,
}
// fill entryMap
if err := store.Refresh(); err != nil {
return nil, err
}
return store, nil
}
func (a *AssetStore) Refresh() error {
rootEntries, err := processDir(a.RootDir)
if err != nil {
return fmt.Errorf("failed to process %v: %v", a.RootDir, err)
}
newEntryMap := make(entryMap, len(rootEntries))
newEntryMap[""] = rootEntries
for lower, natural := range rootEntries {
path := filepath.Join(a.RootDir, natural)
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Failed to stat %v: %v", path, err)
}
if fi.IsDir() {
entries, err := processDir(path)
if err != nil {
return fmt.Errorf("Failed to process %v: %v", path, err)
}
newEntryMap[lower] = entries
}
}
// Refresh
a.aniObj = nil
a.cursorObj = nil
a.cursors = make(map[CursorName]*Cursor)
a.entries = newEntryMap
a.fonts = make(map[string]*Font)
a.idx = nil
a.images = make(map[string]*ebiten.Image)
a.maps = make(map[string]*Map)
a.menus = make(map[string]*Menu)
a.objs = make(map[string]*Object)
a.sets = make(map[string]*Set)
a.sounds = make(map[string]*Sound)
a.strings = nil
return nil
}
func (a *AssetStore) lookup(name, ext string, dirs ...string) (string, error) {
var filename string
if ext != "" {
filename = canonical(name + "." + ext)
} else {
filename = canonical(name)
}
for _, dir := range dirs {
dir = canonical(dir)
if base, ok := a.entries[dir]; ok {
if file, ok := base[filename]; ok {
actualDir := a.entries[""][dir]
return filepath.Join(a.RootDir, actualDir, file), nil
}
}
}
return "", fmt.Errorf("file %q does not exist", filename)
}
func canonical(s string) string {
return strings.ToLower(s)
}
func processDir(dir string) (map[string]string, error) {
entries, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
out := make(map[string]string, len(entries))
for _, entry := range entries {
if entry.Name() == "." || entry.Name() == ".." {
continue
}
out[canonical(entry.Name())] = entry.Name()
}
return out, nil
}

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,29 @@
package assetstore
import (
"code.ur.gs/lupine/ordoor/internal/data"
)
// Internationalisation is completely hidden inside the asset store. Everything
// comes out already converted.
//
// FIXME: Allow the language to be set. Right now, it's hardcoded to USEng
// because that's the only copy of Chaos Gate I have.
func (a *AssetStore) i18n() (*data.I18n, error) {
if a.strings != nil {
return a.strings, nil
}
filename, err := a.lookup(data.I18nFile, "", "Data")
if err != nil {
return nil, err
}
i18n, err := data.LoadI18n(filename)
if err != nil {
return nil, err
}
a.strings = i18n
return i18n, 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
}

106
internal/assetstore/map.go Normal file
View File

@@ -0,0 +1,106 @@
package assetstore
import (
"fmt"
"image"
"log"
"code.ur.gs/lupine/ordoor/internal/maps"
)
type Map struct {
assets *AssetStore
set *Set
Rect image.Rectangle
raw *maps.GameMap
}
// Map loads a game map with the given name (e.g. "Chapter01")
func (a *AssetStore) Map(name string) (*Map, error) {
name = canonical(name)
if m, ok := a.maps[name]; ok {
return m, nil
}
log.Printf("Loading map %v", name)
mapFile, err := a.lookup(name, "map", "Maps", "MultiMaps")
if err != nil {
return nil, err
}
txtFile, err := a.lookup(name, "txt", "Maps", "MultiMaps")
if err != nil {
return nil, err
}
raw, err := maps.LoadGameMapByFiles(mapFile, txtFile)
if err != nil {
return nil, err
}
// The set for a map is small and frequently referenced, so load it here
set, err := a.Set(raw.MapSetName())
if err != nil {
return nil, err
}
m := &Map{
Rect: raw.Rect(),
assets: a,
raw: raw,
set: set,
}
a.maps[canonical(name)] = m
return m, nil
}
func (m *Map) LoadSprites() error {
// Eager load the sprites we use
for x := m.Rect.Min.X; x < m.Rect.Max.X; x++ {
for y := m.Rect.Min.Y; y < m.Rect.Max.Y; y++ {
for z := 0; z < maps.MaxHeight; z++ {
if _, err := m.SpritesForCell(x, y, z); err != nil {
return err
}
}
}
}
return nil
}
// FIXME: get rid of this
func (m *Map) Cell(x, y, z int) maps.Cell {
return m.raw.Cells.At(x, y, z)
}
// SpritesForCell returns the sprites needed to correctly render this cell.
// They should be rendered from first to last to get the correct ordering
func (m *Map) SpritesForCell(x, y, z int) ([]*Sprite, error) {
cell := m.raw.At(x, y, z)
sprites := make([]*Sprite, 0, 4)
for _, ref := range []maps.ObjRef{cell.Surface, cell.Right, cell.Left, cell.Center} {
if !ref.IsActive() {
continue
}
obj, err := m.set.Object(ref.Index())
if err != nil {
return nil, fmt.Errorf("Failed to get object for %#+v: %v", ref, err)
}
sprite, err := obj.Sprite(ref.Sprite())
if err != nil {
return nil, err
}
sprites = append(sprites, sprite)
}
return sprites, nil
}

123
internal/assetstore/menu.go Normal file
View File

@@ -0,0 +1,123 @@
package assetstore
import (
"log"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/menus"
)
type Menu struct {
assets *AssetStore
fonts []*Font // TODO: place the fonts directly into the relevant records
objects []*Object // TODO: place the objects directly into the relevant records
raw *menus.Menu // TODO: remove raw
Name string
}
// FIXME: don't expose this
func (m *Menu) Groups() []*menus.Group {
return m.raw.Groups
}
// FIXME: don't expose this
func (m *Menu) Font(idx int) *Font {
return m.fonts[idx]
}
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
}
func (m *Menu) Sprites(objIdx, start, count int) ([]*Sprite, error) {
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) {
name = canonical(name)
if menu, ok := a.menus[name]; ok {
return menu, nil
}
filename, err := a.lookup(name, "mnu", "Menu")
if err != nil {
return nil, err
}
raw, err := menus.LoadMenu(filename, a.Palette)
if err != nil {
return nil, err
}
var fonts []*Font
for _, fontName := range raw.FontNames {
fnt, err := a.Font(fontName)
if err != nil {
return nil, err
}
fonts = append(fonts, fnt)
}
i18n, err := a.i18n()
if err != nil {
log.Printf("Failed to load i18n data, skipping internationalisatoin: %s", err)
} else {
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
objects, err := a.loadMenuObjects(raw)
if err != nil {
return nil, err
}
menu := &Menu{
assets: a,
fonts: fonts,
objects: objects,
raw: raw,
Name: name,
}
a.menus[name] = menu
return menu, nil
}
func (a *AssetStore) loadMenuObjects(menu *menus.Menu) ([]*Object, error) {
out := make([]*Object, len(menu.ObjectFiles))
for i, name := range menu.ObjectFiles {
filename, err := a.lookup(name, "", "Menu") // Extension already present
if err != nil {
return nil, err
}
obj, err := a.ObjectByPath(filename)
if err != nil {
return nil, err
}
out[i] = obj
}
return out, nil
}

View File

@@ -0,0 +1,146 @@
package assetstore
import (
"fmt"
"image"
"log"
"path/filepath"
"strings"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/data"
)
type Object struct {
assets *AssetStore
sprites []*Sprite
raw *data.Object
NumSprites int
Name string
}
type Sprite struct {
obj *Object
XOffset int // TODO: replace these everywhere with Rect
YOffset int
Width int
Height int
ID string
Rect image.Rectangle
Image *ebiten.Image
}
func (a *AssetStore) Object(name string) (*Object, error) {
name = canonical(name)
if obj, ok := a.objs[name]; ok {
return obj, nil
}
log.Printf("Loading object %v", name)
filename, err := a.lookup(name, "obj", "Obj", "spr")
if err != nil {
return nil, err
}
obj, err := a.ObjectByPath(filename)
if err != nil {
return nil, err
}
a.objs[name] = obj
return obj, nil
}
// FIXME: Objects loaded by path are not cached
func (a *AssetStore) ObjectByPath(path string) (*Object, error) {
name := filepath.Base(path)
name = strings.Replace(name, filepath.Ext(name), "", -1)
name = canonical(name)
raw, err := data.LoadObjectLazily(path)
if err != nil {
return nil, err
}
obj := &Object{
assets: a,
sprites: make([]*Sprite, int(raw.NumSprites)),
raw: raw,
NumSprites: int(raw.NumSprites),
Name: canonical(name),
}
return obj, nil
}
// Loads all sprites in the object eagerly
func (o *Object) LoadSprites() error {
for i := 0; i < o.NumSprites; i++ {
if _, err := o.Sprite(i); err != nil {
return err
}
}
return nil
}
func (o *Object) Sprites(start, count int) ([]*Sprite, error) {
out := make([]*Sprite, count)
for i := start; i < start+count; i++ {
sprite, err := o.Sprite(i)
if err != nil {
return nil, err
}
out[i-start] = sprite
}
return out, nil
}
func (o *Object) Sprite(idx int) (*Sprite, error) {
if sprite := o.sprites[idx]; sprite != nil {
return sprite, nil
}
if o.raw.Sprites[idx] == nil {
if err := o.raw.LoadSprite(idx); err != nil {
return nil, err
}
}
raw := o.raw.Sprites[idx]
img, err := ebiten.NewImageFromImage(raw.ToImage(o.assets.Palette), ebiten.FilterDefault)
if err != nil {
return nil, err
}
rect := image.Rect(
int(raw.XOffset),
int(raw.YOffset),
int(raw.XOffset+raw.Width),
int(raw.YOffset+raw.Height),
)
sprite := &Sprite{
ID: fmt.Sprintf("%v:%v", o.raw.Name, idx),
obj: o,
Width: rect.Dx(),
Height: rect.Dy(),
XOffset: rect.Min.X,
YOffset: rect.Min.Y,
Rect: rect,
Image: img,
}
o.sprites[idx] = sprite
return sprite, nil
}

View File

@@ -0,0 +1,55 @@
package assetstore
import (
"errors"
"log"
"code.ur.gs/lupine/ordoor/internal/sets"
)
var (
ErrOutOfBounds = errors.New("Out of bounds")
)
type Set struct {
assets *AssetStore
raw *sets.MapSet
NumObjects int
}
func (s *Set) Object(idx int) (*Object, error) {
if idx < 0 || idx >= s.NumObjects {
return nil, ErrOutOfBounds
}
return s.assets.Object(s.raw.Palette[idx])
}
func (a *AssetStore) Set(name string) (*Set, error) {
name = canonical(name)
if set, ok := a.sets[name]; ok {
return set, nil
}
log.Printf("Loading set %v", name)
filename, err := a.lookup(name, "set", "Sets")
if err != nil {
return nil, err
}
raw, err := sets.LoadSet(filename)
if err != nil {
return nil, err
}
set := &Set{
assets: a,
raw: raw,
NumObjects: len(raw.Palette),
}
a.sets[name] = set
return set, nil
}

View File

@@ -0,0 +1,78 @@
package assetstore
import (
"log"
"os"
"github.com/hajimehoshi/ebiten/audio"
"github.com/hajimehoshi/ebiten/audio/vorbis"
)
type Sound struct {
Name string
filename string
}
func (a *AssetStore) Sound(name string) (*Sound, error) {
name = canonical(name)
if sound, ok := a.sounds[name]; ok {
return sound, nil
}
// TODO: Data/Sounds.dat + Sounds/wh40k.ds seem to operate together to allow
// attributes and a .wav file to be attached to event names, which could be
// what we use here instead. For now, we're just using the .wav files!
log.Printf("Loading sound %v", name)
// FIXME: a preprocessing script is used to create these files from the
// original ADPCM .wav files. Instead, use the original .wav files.
filename, err := a.lookup(name, "wav.ogg", "Wav")
if err != nil {
return nil, err
}
sound := &Sound{
Name: name,
filename: filename,
}
a.sounds[name] = sound
return sound, nil
}
func (s *Sound) Player() (*audio.Player, error) {
decoder, err := s.decoder()
if err != nil {
return nil, err
}
return audio.NewPlayer(audio.CurrentContext(), decoder)
}
func (s *Sound) InfinitePlayer() (*audio.Player, error) {
decoder, err := s.decoder()
if err != nil {
return nil, err
}
infinite := audio.NewInfiniteLoop(decoder, decoder.Size())
return audio.NewPlayer(audio.CurrentContext(), infinite)
}
func (s *Sound) decoder() (*vorbis.Stream, error) {
f, err := os.Open(s.filename)
if err != nil {
return nil, err
}
decoder, err := vorbis.Decode(audio.CurrentContext(), f)
if err != nil {
_ = f.Close()
return nil, err
}
return decoder, nil
}

View File

@@ -1,21 +1,78 @@
package config package config
import ( import (
"errors"
"fmt"
"os"
"path/filepath" "path/filepath"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
type WH40K 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 in the options hash
// TODO: load defaults from Data/GenericData.dat if they're not set
type Options struct {
PlayMovies bool `toml:"play_movies"`
Animations bool `toml:"animations"`
PlayMusic bool `toml:"play_music"`
CombatVoices bool `toml:"combat_voices"`
ShowGrid bool `toml:"show_grid"`
ShowPaths bool `toml:"show_paths"`
PointSaving bool `toml:"point_saving"`
AutoCutLevel bool `toml:"auto_cut_level"`
XRes int `toml:"x_resolution"`
YRes int `toml:"y_resolution"`
MusicVolume int `toml:"music_volume"`
SFXVolume int `toml:"sfx_volume"`
UnitSpeed int `toml:"unit_speed"`
AnimSpeed int `toml:"animation_speed"`
} }
type Config struct { type Config struct {
WH40K `toml:"wh40k"` filename string `toml:"-"`
VideoPlayer []string `toml:"video_player"`
Engines map[string]Engine `toml:"engines"`
DefaultEngineName string `toml:"default_engine"`
// FIXME: options may well end up being per-engine too
Defaults *Options `toml:"-"`
Options `toml:"options"`
} }
func Load(filename string) (*Config, error) { func (c *Config) Engine(name string) *Engine {
engine, ok := c.Engines[name]
if !ok {
return nil
}
return &engine
}
func (c *Config) DefaultEngine() *Engine {
return c.Engine(c.DefaultEngineName)
}
// TODO: case-insensitive lookup
func (c *Config) DataFile(engine string, path string) string {
cfg, ok := c.Engines[engine]
if !ok {
return ""
}
return filepath.Join(cfg.DataDir, path)
}
func Load(filename string, overrideDefaultEngine string) (*Config, error) {
var out Config var out Config
_, err := toml.DecodeFile(filename, &out) _, err := toml.DecodeFile(filename, &out)
@@ -23,10 +80,74 @@ func Load(filename string) (*Config, error) {
return nil, err return nil, err
} }
return &out, err out.filename = filename
if overrideDefaultEngine != "" {
out.DefaultEngineName = overrideDefaultEngine
}
if out.DefaultEngine() == nil {
return nil, fmt.Errorf("Default engine %q not configured", out.DefaultEngineName)
}
return &out, nil
} }
// TODO: case-insensitive lookup func (c *Config) HasUnsetOptions() bool {
func (c *Config) DataFile(path string) string { var empty Options
return filepath.Join(c.DataDir, path)
return c.Options == empty
}
func (c *Config) Save() error {
f, err := os.OpenFile(c.filename, os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(c)
}
func (c *Config) ResetDefaults() error {
if c.Defaults == nil {
return errors.New("Defaults not available")
}
c.Options = *c.Defaults
return c.Save()
}
func (o *Options) ResolutionIndex() int {
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
} }

View File

@@ -1,37 +0,0 @@
package conv
import (
"fmt"
"github.com/faiface/pixel"
"github.com/faiface/pixel/text"
"golang.org/x/image/colornames"
"golang.org/x/image/font/basicfont"
"ur.gs/ordoor/internal/fonts"
)
type Font struct {
Name string
Atlas *text.Atlas
Text *text.Text
}
func (f *Font) Output(to pixel.Target, m pixel.Matrix, format string, args ...interface{}) {
text := text.New(pixel.V(0.0, 0.0), f.Atlas)
text.Color = colornames.White
fmt.Fprintf(text, format, args...)
text.Draw(to, m)
}
func ConvertFont(font *fonts.Font) *Font {
// FIXME: actually use the pixel data in font
atlas := text.NewAtlas(basicfont.Face7x13, text.ASCII)
return &Font{
Name: font.Name,
Atlas: atlas,
}
}

View File

@@ -1,184 +0,0 @@
package conv
import (
"fmt"
"image/color"
"log"
"github.com/faiface/pixel"
"ur.gs/ordoor/internal/data"
)
const (
// Textures can be this size at most. So putting every sprite onto the same
// sheet is a fraught business. With effective packing, it may be fine.
// If not, I'll have to come up with another idea
maxTextureX = 8192
maxTextureY = 8192
)
// Important conversions:
//
// * Width & height now stored using int
// * Colour data is now 32-bit rather than using a palette
type Sprite struct {
Width int
Height int
Pic *pixel.PictureData
Spr *pixel.Sprite
}
type Object struct {
Name string
Sprites []*Sprite
}
func MapByName(objects []*Object) map[string]*Object {
out := make(map[string]*Object, len(objects))
for _, obj := range objects {
out[obj.Name] = obj
}
return out
}
func ConvertObjects(objects []*data.Object) ([]*Object, *pixel.PictureData) {
// FIXME: this is rather inefficient. It would be better to determine the
// maximum size we need for the objects at hand.
spritesheet := pixel.MakePictureData(
pixel.R(0.0, 0.0, float64(maxTextureX), float64(maxTextureY)),
)
// These are updated each time a sprite is added to the sheet
xOffset := 0
yOffset := 0
rowMaxY := 0
out := make([]*Object, 0, len(objects))
for _, rawObj := range objects {
sprites := make([]*Sprite, 0, len(rawObj.Sprites))
for i, rawSpr := range rawObj.Sprites {
width := int(rawSpr.Width)
height := int(rawSpr.Height)
// CR+LF if needed
if xOffset+width > maxTextureX {
xOffset = 0
yOffset += rowMaxY
}
if xOffset+width > maxTextureX || yOffset+height > maxTextureY {
panic("Sprite does not fit in spritesheet")
}
spr := spriteToPic(rawObj.Name, i, rawSpr, spritesheet, xOffset, yOffset)
sprites = append(sprites, &Sprite{width, height, spritesheet, spr})
xOffset = xOffset + width
if height > rowMaxY {
rowMaxY = height
}
}
out = append(out, &Object{Name: rawObj.Name, Sprites: sprites})
}
return out, spritesheet
}
func ConvertObjectWithSpritesheet(rawObj *data.Object, name string, pic *pixel.PictureData, xOffset int) *Object {
// We store the sprites vertically in the provided pic
yOffset := 0
out := &Object{
Name: name,
Sprites: make([]*Sprite, len(rawObj.Sprites)),
}
for i, rawSpr := range rawObj.Sprites {
spr := spriteToPic(name, i, rawSpr, pic, xOffset, yOffset)
out.Sprites[i] = &Sprite{
Width: int(rawSpr.Width),
Height: int(rawSpr.Height),
Pic: pic,
Spr: spr,
}
yOffset = yOffset + int(rawSpr.Height)
}
return out
}
func ConvertObject(rawObj *data.Object, name string) *Object {
out := &Object{
Name: name,
Sprites: make([]*Sprite, len(rawObj.Sprites)),
}
for i, rawSpr := range rawObj.Sprites {
pic := pixel.MakePictureData(
pixel.R(
float64(0),
float64(0),
float64(rawSpr.Width),
float64(rawSpr.Height),
),
)
spr := spriteToPic(name, i, rawSpr, pic, 0, 0)
out.Sprites[i] = &Sprite{
Width: int(rawSpr.Width),
Height: int(rawSpr.Height),
Pic: pic,
Spr: spr,
}
}
return out
}
func spriteToPic(name string, idx int, sprite *data.Sprite, pic *pixel.PictureData, xOffset, yOffset int) *pixel.Sprite {
width := int(sprite.Width)
height := int(sprite.Height)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
b := sprite.Data[y*width+x]
// Update the picture
if err := setPaletteColor(pic, x+xOffset, y+yOffset, b); err != nil {
log.Printf("%s %d: %d,%d: %v", name, idx, x, y, err)
}
}
}
bounds := pixel.R(
float64(xOffset),
float64(yOffset),
float64(xOffset+width),
float64(yOffset+height),
)
return pixel.NewSprite(pic, bounds)
}
func setPaletteColor(pic *pixel.PictureData, x int, y int, colorIdx byte) error {
vec := pixel.V(float64(x), float64(y))
idx := pic.Index(vec)
if idx > len(pic.Pix)-1 {
return fmt.Errorf("Got index %v which exceeds bounds", idx)
}
r, g, b, a := data.ColorPalette[int(colorIdx)].RGBA()
color := color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)}
pic.Pix[idx] = color
return nil
}

View File

@@ -4,7 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"ur.gs/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
// Comments are `//`, start of line only, for accounting // Comments are `//`, start of line only, for accounting

View File

@@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"ur.gs/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
type Cell struct { type Cell struct {

View File

@@ -1,7 +1,7 @@
package data package data
import ( import (
"ur.gs/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
type CompassPoints struct { type CompassPoints struct {

View File

@@ -1,7 +1,7 @@
package data package data
import ( import (
"ur.gs/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
type ActionPointEnum int type ActionPointEnum int

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

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

View File

@@ -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

@@ -3,27 +3,44 @@ package data
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"image"
"image/color"
"io" "io"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"ur.gs/ordoor/internal/data/rle" "code.ur.gs/lupine/ordoor/internal/data/rle"
) )
type SpriteHeader struct { type SpriteHeader struct {
Unknown0 uint32 XOffset uint16
YOffset uint16
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
@@ -40,6 +57,16 @@ type Sprite struct {
Data []byte Data []byte
} }
func (s *Sprite) ToImage(palette color.Palette) *image.Paletted {
return &image.Paletted{
Pix: s.Data,
Stride: int(s.Width),
Rect: image.Rect(0, 0, int(s.Width), int(s.Height)),
Palette: palette,
}
}
// dirEntry totals 8 bytes on disk
type dirEntry struct { type dirEntry struct {
Offset uint32 // Offset of the sprite relative to the data block Offset uint32 // Offset of the sprite relative to the data block
Size uint32 // Size of the sprite in bytes, including any header Size uint32 // Size of the sprite in bytes, including any header
@@ -84,7 +111,7 @@ type Object struct {
Sprites []*Sprite Sprites []*Sprite
} }
func LoadObject(filename string) (*Object, error) { func LoadObjectLazily(filename string) (*Object, error) {
f, err := os.Open(filename) f, err := os.Open(filename)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -101,53 +128,85 @@ func LoadObject(filename string) (*Object, error) {
return nil, err return nil, err
} }
// Now load all sprites into memory out.Sprites = make([]*Sprite, int(out.NumSprites))
dir := make([]dirEntry, out.NumSprites)
if _, err := f.Seek(int64(out.DirOffset), io.SeekStart); err != nil {
return nil, fmt.Errorf("Seeking to sprite directory: %v", err)
}
if err := binary.Read(f, binary.LittleEndian, &dir); err != nil {
return nil, fmt.Errorf("Reading sprite directory: %v", err)
}
if _, err := f.Seek(int64(out.DataOffset), io.SeekStart); err != nil {
return nil, fmt.Errorf("Seeking to sprites: %v", err)
}
for i, dirEntry := range dir {
if err := dirEntry.Check(); err != nil {
return nil, err
}
if _, err := f.Seek(int64(out.DataOffset+dirEntry.Offset), io.SeekStart); err != nil {
return nil, fmt.Errorf("Seeking to sprite %v: %v", i, err)
}
sprite := &Sprite{}
if err := binary.Read(f, binary.LittleEndian, &sprite.SpriteHeader); err != nil {
return nil, fmt.Errorf("Reading sprite %v header: %v", i, err)
}
if err := sprite.Check(dirEntry.Size); err != nil {
return nil, err
}
buf := io.LimitReader(f, int64(sprite.PixelSize))
sprite.Data = make([]byte, int(sprite.Height)*int(sprite.Width))
// The pixel data is RLE-compressed. Uncompress it here.
if err := rle.Expand(buf, sprite.Data); err != nil {
return nil, err
}
out.Sprites = append(out.Sprites, sprite)
}
return out, nil return out, nil
} }
func LoadObject(filename string) (*Object, error) {
obj, err := LoadObjectLazily(filename)
if err != nil {
return nil, err
}
if err := obj.LoadAllSprites(); err != nil {
return nil, err
}
return obj, nil
}
func (o *Object) LoadAllSprites() error {
for i := 0; i < int(o.NumSprites); i++ {
if err := o.LoadSprite(i); err != nil {
return err
}
}
return nil
}
func (o *Object) LoadSprite(idx int) error {
if idx < 0 || idx >= int(o.NumSprites) {
return fmt.Errorf("Asked for idx %v of %v", idx, o.NumSprites)
}
f, err := os.Open(o.Filename)
if err != nil {
return err
}
defer f.Close()
var entry dirEntry
if _, err := f.Seek(int64(o.DirOffset)+int64(idx*8), io.SeekStart); err != nil {
return fmt.Errorf("Seeking to sprite directory entry %v: %v", idx, err)
}
if err := binary.Read(f, binary.LittleEndian, &entry); err != nil {
return fmt.Errorf("Reading sprite directory entry %v: %v", idx, err)
}
if err := entry.Check(); err != nil {
return err
}
if _, err := f.Seek(int64(o.DataOffset+entry.Offset), io.SeekStart); err != nil {
return fmt.Errorf("Seeking to sprite %v: %v", idx, err)
}
sprite := &Sprite{}
if err := binary.Read(f, binary.LittleEndian, &sprite.SpriteHeader); err != nil {
return fmt.Errorf("Reading sprite %v header: %v", idx, err)
}
if err := sprite.Check(entry.Size); err != nil {
return err
}
buf := io.LimitReader(f, int64(sprite.PixelSize))
sprite.Data = make([]byte, int(sprite.Height)*int(sprite.Width))
// The pixel data is RLE-compressed. Uncompress it here.
if err := rle.Expand(buf, sprite.Data); err != nil {
return err
}
o.Sprites[idx] = sprite
return nil
}
func LoadObjects(dir string) (map[string]*Object, error) { func LoadObjects(dir string) (map[string]*Object, error) {
fis, err := ioutil.ReadDir(dir) fis, err := ioutil.ReadDir(dir)
if err != nil { if err != nil {

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

@@ -6,21 +6,32 @@ import (
"strconv" "strconv"
"strings" "strings"
"ur.gs/ordoor/internal/util" "code.ur.gs/lupine/ordoor/internal/util"
"ur.gs/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,48 +25,64 @@ 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
} }
func (h Header) MapSetFilename() string { type Characters []Character
// TODO. These are triggers/reactors/etc.
type Thingy struct {}
type Thingies []Thingy
func (h Header) MapSetName() string {
idx := bytes.IndexByte(h.SetName[:], 0) idx := bytes.IndexByte(h.SetName[:], 0)
if idx < 0 { if idx < 0 {
idx = 8 // all 8 bytes are used idx = 8 // all 8 bytes are used
} }
return string(h.SetName[0:idx:idx]) + ".set" return string(h.SetName[0:idx:idx])
}
func (h Header) MapSetFilename() string {
return h.MapSetName() + ".set"
} }
type ObjRef struct { type ObjRef struct {
@@ -75,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 {
@@ -86,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
@@ -100,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
@@ -145,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))
@@ -165,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) {
@@ -272,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,29 +2,78 @@ package menus
import ( import (
"fmt" "fmt"
"image"
"image/color"
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"ur.gs/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
type Record struct { // MenuType tells us what sort of Group we have
Parent *Record type MenuType int
Children []*Record
Id int // SubMenuType tells us what sort of Record we have
Type int type SubMenuType int
FontType int
Active bool
SpriteId []int
X int
Y int
Desc string
// FIXME: turn these into first-class data const (
properties map[string]string TypeStatic MenuType = 0
TypeMenu MenuType = 1
TypeDragMenu MenuType = 2 // Only seen in Configure_Vehicle_{Chaos,Ultra}
TypeRadioMenu MenuType = 3 // ???
TypeMainBackground MenuType = 45 // ???
TypeDialogue MenuType = 300
SubTypeSimpleButton SubMenuType = 3
SubTypeDoorHotspot1 SubMenuType = 30 // Like a button I guess? "FONTTYPE is animation speed"
SubTypeDoorHotspot2 SubMenuType = 31 // Seems like a duplicate of the above? What's different?
SubTypeLineKbd SubMenuType = 40
SubTypeLineBriefing SubMenuType = 41
SubTypeThumb SubMenuType = 45 // A "thumb" appears to be a vertical slider
SubTypeInvokeButton SubMenuType = 50
SubTypeDoorHotspot3 SubMenuType = 60 // Maybe? Appears in Arrange.mnu
SubTypeOverlay SubMenuType = 61
SubTypeHypertext SubMenuType = 70
SubTypeCheckbox SubMenuType = 91
SubTypeEditBox SubMenuType = 100
SubTypeInventorySelect SubMenuType = 110
SubTypeRadioButton SubMenuType = 120
SubTypeDropdownButton SubMenuType = 200
SubTypeComboBoxItem SubMenuType = 205
SubTypeAnimationSample SubMenuType = 220
SubTypeAnimationHover SubMenuType = 221 // FONTTYPE is animation speed. Only animate when hovered
SubTypeMainButton SubMenuType = 228
SubTypeSlider SubMenuType = 232
SubTypeStatusBar SubMenuType = 233
SubTypeListBoxUp SubMenuType = 400 // FIXME: these have multiple items in SUBMENUTYPE
SubTypeListBoxDown SubMenuType = 405
)
// FIXME: certain elements - especially overlays - don't have a DESC specified
// in the .mnu file, but display text specified with a number in i18n. The only
// conclusion I can draw is that they're hardcoded in the binary and set from
// outside. So, do that here.
var DescOverrides = map[string]int{
"main:2.6": 50992,
"newgame:2.5": 50993,
"keyboard:3.3": 50995,
"levelply:2.6": 50996,
}
// FIXME: Same idea with text overrides, only these aren't mentioned in the .dta
// file at all!
var TextOverrides = map[string]string{
"main:2.7": "0.1-ordoor",
}
var TypeOverrides = map[string]SubMenuType{
// FIXME: These are put down as simple buttons, but it's a *lot* easier to
// understand them as list box buttons.
"configure_ultequip:7.5": SubTypeListBoxUp,
"configure_ultequip:7.6": SubTypeListBoxDown,
} }
type Menu struct { type Menu struct {
@@ -33,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
@@ -52,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
@@ -129,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)
} }
@@ -140,75 +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 = 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
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
}

203
internal/ordoor/ordoor.go Normal file
View File

@@ -0,0 +1,203 @@
// package ordoor implements the full WH40K.EXE functionality, and is used from
// cmd/ordoor/main.go
//
// Entrypoint is Run()
package ordoor
import (
"fmt"
"log"
"time"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/audio"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/flow"
"code.ur.gs/lupine/ordoor/internal/ship"
"code.ur.gs/lupine/ordoor/internal/ui"
)
type gameState int
type Ordoor struct {
assets *assetstore.AssetStore
config *config.Config
music *audio.Player
win *ui.Window
// Relevant to interface state
flow *flow.Flow
// FIXME: should be put inside flow
// If this is set, we display it instead of flow
pic *ebiten.Image
// Relevant to campaign state
ship *ship.Ship
}
func Run(configFile string, overrideX, overrideY int) error {
cfg, err := config.Load(configFile, "ordoor")
if err != nil {
return fmt.Errorf("Couldn't load config file: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
return fmt.Errorf("Failed to initialize asset store: %v", err)
}
defaults, err := assets.DefaultOptions()
if err != nil {
return fmt.Errorf("Failed to read option defaults: %v", err)
}
cfg.Defaults = defaults
if cfg.HasUnsetOptions() {
if err := cfg.ResetDefaults(); err != nil {
return fmt.Errorf("Failed to set options on first-start: %v", err)
}
}
if _, err := audio.NewContext(48000); err != nil {
return fmt.Errorf("Failed to set up audio context: %v", err)
}
ordoor := &Ordoor{
assets: assets,
config: cfg,
ship: ship.New(),
}
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 {
return fmt.Errorf("Failed to create window: %v", err)
}
ordoor.win = win
if err := ordoor.Run(); err != nil {
return fmt.Errorf("Run finished with error: %v", err)
}
return nil
}
func (o *Ordoor) Run() error {
// FIXME: these should be displayed *after*, not *before*, the copyright
if o.config.Options.PlayMovies {
o.PlaySkippableVideo("LOGOS")
o.PlaySkippableVideo("movie1")
}
if err := o.DisplayImageFor(time.Second, "copyright"); err != nil {
log.Printf("Failed to display copyright image: %v", err)
}
err := o.win.Run()
if err == flow.ErrExit {
log.Printf("Exit requested")
return nil
}
return err
}
// Only one music track can play at a time. This is handled at the toplevel.
// FIXME: should take references from Sounds.dat
// FIXME: music probably properly belongs to flow. This package can just do
// initialization and wire the flow to the ship?
func (o *Ordoor) PlayMusic(name string) error {
if o.music != nil {
if err := o.music.Close(); err != nil {
return fmt.Errorf("Failed to close old music: %v", err)
}
}
sound, err := o.assets.Sound(name)
if err != nil {
return fmt.Errorf("Failed to find sound %v: %v", name, err)
}
player, err := sound.InfinitePlayer()
if err != nil {
return fmt.Errorf("Failed to generate music player for %v: %v", name, err)
}
o.music = player
if o.config.Options.PlayMusic {
player.Play()
}
return nil
}
func (o *Ordoor) setupFlow() error {
o.PlayMusic("music_interface")
flow, err := flow.New(o.assets, o.config, o.ship)
if err != nil {
return err
}
o.flow = flow
return nil
}
func (o *Ordoor) Update(screenX, screenY int) error {
if pic := o.pic; pic != nil {
return nil // Ignore flow until we don't have a pic any more
}
if o.flow == nil {
if err := o.setupFlow(); err != nil {
return fmt.Errorf("failed to setup UI flow: %v", err)
}
}
// Ensure music is doing the right thing
if o.music != nil && o.music.IsPlaying() != o.config.Options.PlayMusic {
if o.config.Options.PlayMusic {
o.music.Rewind()
o.music.Play()
} else {
o.music.Pause()
}
}
return o.flow.Update(screenX, screenY)
}
func (o *Ordoor) Draw(screen *ebiten.Image) error {
if pic := o.pic; pic != nil {
// Scale the picture to the screen and draw it
scaleX := float64(screen.Bounds().Dx()) / float64(pic.Bounds().Dx())
scaleY := float64(screen.Bounds().Dy()) / float64(pic.Bounds().Dy())
do := &ebiten.DrawImageOptions{}
do.GeoM.Scale(scaleX, scaleY)
return screen.DrawImage(pic, do)
}
return o.flow.Draw(screen)
}
func (o *Ordoor) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
if o.flow != nil {
return o.flow.Cursor()
}
return nil, nil, nil
}

35
internal/ordoor/videos.go Normal file
View File

@@ -0,0 +1,35 @@
package ordoor
import (
"log"
"os/exec"
)
func (o *Ordoor) PlayVideo(name string, skippable bool) {
filename := o.config.DataFile("ordoor", "SMK/"+name+".smk")
if len(o.config.VideoPlayer) == 0 {
log.Printf("Video player not configured, skipping video %v", filename)
return
}
argc := o.config.VideoPlayer[0]
argv := append(o.config.VideoPlayer[1:])
if skippable {
argv = append(argv, "--input-conf=skippable.mpv.conf")
}
argv = append(argv, filename)
if err := exec.Command(argc, argv...).Run(); err != nil {
log.Printf("Error playing video %v: %v", filename, err)
}
}
func (o *Ordoor) PlayUnskippableVideo(name string) {
o.PlayVideo(name, false)
}
func (o *Ordoor) PlaySkippableVideo(name string) {
o.PlayVideo(name, true)
}

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
}

View File

@@ -7,7 +7,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"ur.gs/ordoor/internal/util/asciiscan" "code.ur.gs/lupine/ordoor/internal/util/asciiscan"
) )
type MapSet struct { type MapSet struct {

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
}

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

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

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

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

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

View File

@@ -2,46 +2,196 @@ package ui
import ( import (
"flag" "flag"
"fmt"
"log"
"os"
"runtime/debug"
"runtime/pprof"
"github.com/faiface/pixel" "github.com/hajimehoshi/ebiten"
"github.com/faiface/pixel/pixelgl" "github.com/hajimehoshi/ebiten/ebitenutil"
"github.com/hajimehoshi/ebiten/inpututil"
) )
var ( type Game interface {
winX = flag.Int("win-x", 1280, "width of the view-map window") Update(screenX, screenY int) error
winY = flag.Int("win-y", 1024, "height of the view-map window") Draw(*ebiten.Image) error
)
type Window struct {
PixelWindow *pixelgl.Window
ButtonEventHandlers map[pixelgl.Button][]func()
} }
// WARE! 0,0 is the *bottom left* of the window type CustomCursor interface {
// // The cursor draw operation
// To invert things so 0,0 is the *top left*, apply the InvertY` matrix Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error)
func NewWindow(title string) (*Window, error) { }
cfg := pixelgl.WindowConfig{
Title: title,
Bounds: pixel.R(0, 0, float64(*winX), float64(*winY)),
VSync: true,
Resizable: true,
}
pixelWindow, err := pixelgl.NewWindow(cfg) var (
if err != nil { screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor")
return nil, err cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
} )
// TODO: move all scaling into Window, so drivers only need to cope with one
// coordinate space. This will allow us to draw custom mouse cursors in the
// window, rather than in the driver.
type Window struct {
Title string
KeyUpHandlers map[ebiten.Key]func()
MouseWheelHandler func(float64, float64)
MouseClickHandler func()
WhileKeyDownHandlers map[ebiten.Key]func()
// Allow the "game" to be switched out at any time
game Game
debug bool
firstRun bool
xRes int
yRes int
}
// 0,0 is the *top left* of the window
//
// ebiten assumes a single window, so only call this once...
func NewWindow(game Game, title string, xRes int, yRes int) (*Window, error) {
ebiten.SetRunnableInBackground(true)
return &Window{ return &Window{
PixelWindow: pixelWindow, Title: title,
debug: true,
firstRun: true,
game: game,
xRes: xRes,
yRes: yRes,
WhileKeyDownHandlers: make(map[ebiten.Key]func()),
KeyUpHandlers: make(map[ebiten.Key]func()),
}, nil }, nil
} }
// TODO: a stop or other cancellation mechanism // TODO: multiple handlers for the same key?
func (w *Window) Run(f func()) { func (w *Window) OnKeyUp(key ebiten.Key, f func()) {
for !w.PixelWindow.Closed() { w.KeyUpHandlers[key] = f
f() }
w.PixelWindow.Update()
} func (w *Window) WhileKeyDown(key ebiten.Key, f func()) {
w.WhileKeyDownHandlers[key] = f
}
func (w *Window) OnMouseWheel(f func(x, y float64)) {
w.MouseWheelHandler = f
}
func (w *Window) OnMouseClick(f func()) {
w.MouseClickHandler = f
}
func (w *Window) Layout(_, _ int) (int, int) {
return w.xRes, w.yRes
}
func (w *Window) drawCursor(screen *ebiten.Image) error {
cIface, ok := w.game.(CustomCursor)
if !ok {
return nil
}
cursor, op, err := cIface.Cursor()
if err != nil {
return err
}
// Hide the system cursor if we have a custom one
if cursor == nil {
ebiten.SetCursorMode(ebiten.CursorModeVisible)
return nil
}
ebiten.SetCursorMode(ebiten.CursorModeHidden)
return screen.DrawImage(cursor, op)
}
func (w *Window) Update(screen *ebiten.Image) (outErr error) {
// Ebiten does not like it if we panic inside its main loop
defer func() {
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 {
return err
}
// Process keys.
// FIXME: : should this happen before or after update?
// TODO: efficient set operations
for key, cb := range w.KeyUpHandlers {
if inpututil.IsKeyJustReleased(key) {
cb()
}
}
for key, cb := range w.WhileKeyDownHandlers {
if ebiten.IsKeyPressed(key) {
cb()
}
}
if w.MouseWheelHandler != nil {
x, y := ebiten.Wheel()
if x != 0 || y != 0 {
w.MouseWheelHandler(x, y)
}
}
if w.MouseClickHandler != nil {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
w.MouseClickHandler()
}
}
if ebiten.IsDrawingSkipped() {
return nil
}
if err := w.game.Draw(screen); err != nil {
return err
}
if w.debug {
// Draw FPS, etc, to the screen
msg := fmt.Sprintf("tps=%0.2f fps=%0.2f", ebiten.CurrentTPS(), ebiten.CurrentFPS())
ebitenutil.DebugPrint(screen, msg)
}
// Draw the cursor last
return w.drawCursor(screen)
}
// TODO: a stop or other cancellation mechanism
//
// Note that this must be called on the main OS thread
func (w *Window) Run() error {
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
log.Fatal("could not create CPU profile: ", err)
}
defer f.Close() // error handling omitted for example
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal("could not start CPU profile: ", err)
}
defer pprof.StopCPUProfile()
}
ebiten.SetWindowSize(int(float64(w.xRes)*(*screenScale)), int(float64(w.yRes)*(*screenScale)))
ebiten.SetWindowTitle(w.Title)
return ebiten.RunGame(w)
} }

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,36 +0,0 @@
package wh40k
import (
"log"
"os/exec"
)
func (w *WH40K) PlayVideo(name string, skippable bool) {
// TODO: allow the video to be skipped by pressing the ESC key or so. For
// now, skip unconditionally
if skippable {
log.Printf("TODO: Make videos conditionally skippable")
return
}
filename := w.Config.DataFile("SMK/" + name + ".smk")
if len(w.Config.VideoPlayer) == 0 {
log.Printf("Video player not configured, skipping video %v", filename)
return
}
argc := w.Config.VideoPlayer[0]
argv := append(w.Config.VideoPlayer[1:], filename)
if err := exec.Command(argc, argv...).Run(); err != nil {
log.Printf("Error playing video %v: %v", filename, err)
}
}
func (w *WH40K) PlayUnskippableVideo(name string) {
w.PlayVideo(name, false)
}
func (w *WH40K) PlaySkippableVideo(name string) {
w.PlayVideo(name, true)
}

View File

@@ -1,30 +0,0 @@
// package wh40k implements the full WH40K.EXE functionality, and is used from
// cmd/wh40k/main.go
//
// Entrypoint is Run()
package wh40k
import (
"fmt"
"ur.gs/ordoor/internal/config"
)
type WH40K struct {
Config *config.Config
}
func Run(configFile string) error {
cfg, err := config.Load(configFile)
if err != nil {
return fmt.Errorf("Couldn't load config file: %v", err)
}
wh40k := &WH40K{
Config: cfg,
}
wh40k.PlayUnskippableVideo("LOGOS")
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.

Some files were not shown because too many files have changed in this diff Show More