Compare commits

...

102 Commits

Author SHA1 Message Date
55c2232e08 Fix a SIGSEGV 2024-10-23 23:54:48 +01:00
ac4675fa2c Fix some log.Fatal calls 2024-10-23 23:47:55 +01:00
5f7654d267 Update dependencies 2024-10-23 23:45:26 +01:00
89888ce004 Stringify AnimAction 2024-10-23 23:44:44 +01:00
16767da6f1 Bump a few more dependencies 2023-07-16 23:11:01 +01:00
c5b80ed8bc Bump ebiten 2023-07-16 22:51:16 +01:00
891edecc60 Update dependencies 2023-01-17 22:10:46 +00:00
85979834c8 Update ebiten and golang 2021-10-23 22:44:39 +01:00
96dbb297cd Bump ebiten 2021-03-01 22:26:45 +00:00
92fa0fc5d6 UNTESTED: ebiten v2 2020-11-21 19:27:09 +00:00
c5e6abb798 First attempt at character orientation 2020-06-13 23:10:21 +01:00
5df050b4ef Substitute unknown glyphs 2020-06-13 18:23:50 +01:00
4d336b9189 Get character stats (kind of) displaying in-scenario 2020-06-13 18:11:45 +01:00
3b7cfb6ecc Drag flow into view-map
This is pretty awful, but will let me wire up items more easily without
needing to do the big refactor into independent menu handlers
2020-06-13 16:37:39 +01:00
7677c30572 Start displaying characters on maps 2020-06-13 15:07:32 +01:00
eac6017c2c Count sprite draw calls 2020-06-13 13:42:26 +01:00
f971ba320c Some more character investigations 2020-06-11 02:54:57 +01:00
cf624cc77b Switch from encoding/binary to struc
It's not perfect, but struc can deserialize the whole thing into one
struct while encoding/binary can't. It's nice to have that.
2020-06-09 00:36:56 +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
93 changed files with 8231 additions and 2239 deletions

17
.gitignore vendored
View File

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

View File

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

192
README.md
View File

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

View File

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

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

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

@@ -0,0 +1,177 @@
package main
import (
"flag"
"image"
"log"
"math"
"os"
"github.com/hajimehoshi/ebiten/v2"
"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, 0) // FIXME: why 0?
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)]
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)
}

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

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

View File

@@ -2,222 +2,97 @@ package main
import (
"flag"
"image"
"log"
"os"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/menus"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/audio"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
menuFile = flag.String("menu", "", "Name of a menu, e.g. Main")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
menuName = flag.String("menu", "", "Name of a menu, e.g. Main")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
)
type env struct {
menu *menus.Menu
objects []*assetstore.Object
// fonts []*assetstore.Font
// fontObjs []*assetstore.Object
step int
state state
lastState state
}
type state struct {
// Redraw the window if these change
winBounds image.Rectangle
type dlg struct {
driver *ui.Driver
list []string
pos int
}
func main() {
flag.Parse()
if *gamePath == "" || *menuFile == "" {
if *configFile == "" || *menuName == "" {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatal(err)
}
menu, err := menus.LoadMenu(*menuFile)
menu, err := assets.Menu(*menuName)
if err != nil {
log.Fatalf("Couldn't load menu file %s: %v", *menuFile, err)
log.Fatalf("Couldn't load menu %s: %v", *menuName, err)
}
if i18n, err := data.LoadI18n(filepath.Join(*gamePath, "Data", data.I18nFile)); err != nil {
log.Printf("Failed to load i18n data, skipping internationalization: %v", err)
} else {
menu.Internationalize(i18n)
driver, err := ui.NewDriver(assets, menu)
if err != nil {
log.Fatalf("Couldn't initialize interface: %v", err)
}
// loadedFonts, err := loadFonts(menu.FontNames...)
// if err != nil {
// log.Fatalf("Failed to load font: %v", err)
// }
win, err := ui.NewWindow(driver, "View Menu: "+*menuName, *winX, *winY)
if err != nil {
log.Fatalf("Couldn't create window: %v", err)
}
var menuObjs []*assetstore.Object
for _, filename := range menu.ObjectFiles {
obj, err := assets.ObjectByPath(filepath.Join(*gamePath, "Menu", filename))
if err != nil {
log.Fatalf("Failed to load %v: %v", filename, err)
// Change the active dialogue
dialogues := driver.Dialogues()
if len(dialogues) > 0 {
dlg := &dlg{
driver: driver,
list: dialogues,
}
win.OnKeyUp(ebiten.KeyLeft, dlg.changeDialogue(-1))
win.OnKeyUp(ebiten.KeyRight, dlg.changeDialogue(+1))
for i, dialogue := range dlg.list {
log.Printf("Dialogue %v: %v", i, dialogue)
}
menuObjs = append(menuObjs, obj)
}
// Yay sound
if _, err := audio.NewContext(48000); err != nil {
log.Fatalf("Failed to audio: %v", err)
}
music, err := assets.Sound("music_interface") // FIXME: should be a reference to Sounds.dat
if err != nil {
log.Fatalf("Failed to find interface music: %v", err)
}
player, err := music.InfinitePlayer()
if err != nil {
log.Fatalf("Failed to generate music player for interface: %v", err)
}
player.Play()
state := state{}
env := &env{
menu: menu,
objects: menuObjs,
// fonts: loadedFonts,
state: state,
lastState: state,
}
win, err := ui.NewWindow("View Menu: " + *menuFile)
if err != nil {
log.Fatal("Couldn't create window: %v", err)
}
if err := win.Run(env.Update, env.Draw); err != nil {
if err := win.Run(); err != nil {
log.Fatal(err)
}
}
func (e *env) Update() error {
// No behaviour yet
e.step += 1
e.lastState = e.state
return nil
}
const (
origX = 640.0
origY = 480.0
)
func (e *env) Draw(screen *ebiten.Image) error {
// 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 := screen.Bounds().Max
scaleX := float64(winSize.X) / float64(origX)
scaleY := float64(winSize.Y) / float64(origY)
cam := ebiten.GeoM{}
cam.Scale(scaleX, scaleY)
for _, record := range e.menu.Records {
if err := e.drawRecordRecursive(record, screen, cam); err != nil {
return err
func (d *dlg) changeDialogue(by int) func() {
return func() {
newPos := d.pos + by
if newPos < 0 || newPos > len(d.list)-1 {
log.Printf("Hiding dialogue %v: %q", d.pos, d.list[d.pos])
d.driver.HideDialogue()
return
}
}
return nil
}
func (e *env) drawRecordRecursive(record *menus.Record, screen *ebiten.Image, geo ebiten.GeoM) error {
if err := e.drawRecord(record, screen, geo); err != nil {
return err
}
// Draw all children of this record
for _, child := range record.Children {
if err := e.drawRecordRecursive(child, screen, geo); err != nil {
return err
}
}
return nil
}
// If the record has a "share" type, we can work out whether it's
func (e *env) isFocused(record *menus.Record, geo ebiten.GeoM) bool {
if record.Share < 0 {
return false
}
sprite, err := e.objects[0].Sprite(record.Share) // FIXME: need to handle multiple objects
if err != nil {
return false
}
invGeo := geo
invGeo.Invert()
cX, cY := ebiten.CursorPosition()
cursorX, cursorY := invGeo.Apply(float64(cX), float64(cY)) // Undo screen scaling
cursorPoint := image.Pt(int(cursorX), int(cursorY))
return cursorPoint.In(sprite.Rect)
}
func (e *env) drawRecord(record *menus.Record, screen *ebiten.Image, geo ebiten.GeoM) error {
// Draw this record if it's valid to do so. FIXME: lots to learn
spriteId := record.SelectSprite(
e.step/2,
ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft),
e.isFocused(record, geo),
)
if spriteId < 0 {
return nil
}
// X-CORD and Y-CORD are universally either 0 or -1, so ignore here.
// TODO: maybe 0 overrides in-sprite offset (set below)?
// FIXME: Need to handle multiple objects
obj := e.objects[0]
sprite, err := obj.Sprite(spriteId)
if err != nil {
return err
}
// Account for scaling, draw sprite at its specified offset
x, y := geo.Apply(float64(sprite.XOffset), float64(sprite.YOffset))
// log.Printf(
// "Drawing id=%v type=%v spriteid=%v x=%v(+%v) y=%v(%+v) desc=%q parent=%p",
// record.Id, record.Type, spriteId, record.X, record.Y, sprite.XOffset, sprite.YOffset, record.Desc, record.Parent,
// )
geo.Translate(x, y)
screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: geo})
// FIXME: we probably shouldn't draw everything?
// FIXME: handle multiple fonts
// if len(e.fonts) > 0 && record.Desc != "" {
// e.fonts[0].Output(screen, origOffset, record.Desc)
// }
return nil
locator := d.list[newPos]
log.Printf("Showing dialogue %v: %q", newPos, locator)
d.driver.ShowDialogue(locator)
d.pos = newPos
}
}

View File

@@ -10,7 +10,7 @@ import (
"path/filepath"
"time"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/v2"
"code.ur.gs/lupine/ordoor/internal/maps"
"code.ur.gs/lupine/ordoor/internal/sets"
@@ -21,6 +21,9 @@ var (
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")
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 {
@@ -71,9 +74,9 @@ func main() {
}
env := &env{gameMap: gameMap, set: mapSet, state: state, lastState: state}
win, err := ui.NewWindow("View Map " + *mapFile)
win, err := ui.NewWindow(env, "View Map "+*mapFile, *winX, *winY)
if err != nil {
log.Fatal("Couldn't create window: %v", err)
log.Fatalf("Couldn't create window: %v", err)
}
win.OnKeyUp(ebiten.KeyEnter, env.toggleAutoUpdate)
@@ -92,7 +95,7 @@ func main() {
win.OnMouseWheel(env.changeZoom)
if err := win.Run(env.Update, env.Draw); err != nil {
if err := win.Run(); err != nil {
log.Fatal(err)
}
}
@@ -137,7 +140,7 @@ func (e *env) changeZoom(_, y float64) {
e.state.zoom *= math.Pow(1.2, y)
}
func (e *env) Update() error {
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
@@ -169,20 +172,13 @@ func (e *env) Update() error {
func (e *env) Draw(screen *ebiten.Image) error {
gameMap := e.gameMap
imd, err := ebiten.NewImage(
int(gameMap.MaxWidth),
int(gameMap.MaxLength),
ebiten.FilterDefault,
)
rect := gameMap.Rect()
imd := ebiten.NewImage(rect.Dx(), rect.Dy())
if err != nil {
return err
}
for y := int(gameMap.MinLength); y < int(gameMap.MaxLength); y++ {
for x := int(gameMap.MinWidth); x < int(gameMap.MaxWidth); x++ {
cell := gameMap.Cells.At(x, y, int(e.state.zIdx))
imd.Set(x, y, makeColour(&cell, e.state.cellIdx))
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.At(x, y, int(e.state.zIdx))
imd.Set(x, y, makeColour(cell, e.state.cellIdx))
}
}
@@ -193,7 +189,9 @@ func (e *env) Draw(screen *ebiten.Image) error {
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})
screen.DrawImage(imd, &ebiten.DrawImageOptions{GeoM: cam})
return nil
}
// Converts pixel coordinates to cell coordinates

View File

@@ -7,20 +7,28 @@ import (
"math"
"os"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/v2"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
objFile = flag.String("obj-file", "", "Path of an .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ")
objName = flag.String("obj-name", "", "Name of an .obj file, e.g. TZEENTCH")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
objFile = flag.String("obj-file", "", "Path of an .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ")
objName = flag.String("obj-name", "", "Name of an .obj file, e.g. TZEENTCH")
sprIdx = flag.Int("spr-idx", 0, "Sprite index to start at")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
)
type env struct {
obj *assetstore.Object
spr *assetstore.Sprite
step int
state state
@@ -37,14 +45,19 @@ type state struct {
func main() {
flag.Parse()
if *gamePath == "" || (*objName == "" && *objFile == "") {
if *configFile == "" || (*objName == "" && *objFile == "") {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatal("Failed to set up asset store: %v", err)
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatalf("Failed to set up asset store: %v", err)
}
var obj *assetstore.Object
@@ -58,8 +71,9 @@ func main() {
}
state := state{
zoom: 6.0,
origin: image.Point{0, 0},
zoom: 6.0,
origin: image.Point{0, 0},
spriteIdx: *sprIdx,
}
env := &env{
@@ -68,7 +82,7 @@ func main() {
lastState: state,
}
win, err := ui.NewWindow("View Object: " + *objName)
win, err := ui.NewWindow(env, "View Object: "+*objName, *winX, *winY)
if err != nil {
log.Fatal(err)
}
@@ -82,17 +96,24 @@ func main() {
win.OnMouseWheel(env.changeZoom)
// The main thread now belongs to ebiten
if err := win.Run(env.Update, env.Draw); err != nil {
if err := win.Run(); err != nil {
log.Fatal(err)
}
}
func (e *env) Update() error {
func (e *env) Update(screenX, screenY int) error {
if e.step == 0 || e.lastState != e.state {
sprite, err := e.obj.Sprite(e.state.spriteIdx)
if err != nil {
return err
}
e.spr = sprite
log.Printf(
"new state: sprite=%d/%d zoom=%.2f, origin=%+v",
"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,
)
@@ -115,7 +136,9 @@ func (e *env) Draw(screen *ebiten.Image) error {
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})
screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: cam})
return nil
}
func (e *env) changeSprite(by int) func() {

View File

@@ -7,15 +7,21 @@ import (
"math"
"os"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/v2"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
setName = flag.String("set", "", "Name of a set, e.g., map01")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
setName = flag.String("set", "", "Name of a set, e.g., map01")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
)
type env struct {
@@ -36,12 +42,17 @@ type state struct {
func main() {
flag.Parse()
if *gamePath == "" || *setName == "" {
if *configFile == "" || *setName == "" {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatal(err)
}
@@ -51,11 +62,6 @@ func main() {
log.Fatalf("Couldn't load set %s: %v", *setName, err)
}
win, err := ui.NewWindow("View Set: " + *setName)
if err != nil {
log.Fatal("Couldn't create window: %v", err)
}
state := state{zoom: 8.0}
env := &env{
set: set,
@@ -63,6 +69,11 @@ func main() {
lastState: state,
}
win, err := ui.NewWindow(env, "View Set: "+*setName, *winX, *winY)
if err != nil {
log.Fatalf("Couldn't create window: %v", err)
}
win.OnKeyUp(ebiten.KeyLeft, env.changeObjIdx(-1))
win.OnKeyUp(ebiten.KeyRight, env.changeObjIdx(+1))
@@ -72,12 +83,12 @@ func main() {
win.OnMouseWheel(env.changeZoom)
// Main thread now belongs to ebiten
if err := win.Run(env.Update, env.Draw); err != nil {
if err := win.Run(); err != nil {
log.Fatal(err)
}
}
func (e *env) Update() error {
func (e *env) Update(screenX, screenY int) error {
curObj, err := e.curObject()
if err != nil {
return err
@@ -112,7 +123,9 @@ func (e *env) Draw(screen *ebiten.Image) error {
// TODO: centre the image
return screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: cam})
screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: cam})
return nil
}
func (e *env) changeObjIdx(by int) func() {

View File

@@ -1,21 +0,0 @@
package main
import (
"log"
"os"
"code.ur.gs/lupine/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]
data_dir = "./orig"
video_player = [
"mpv",
"--no-config", "--keep-open=no", "--force-window=no", "--no-border",
"--no-osc", "--fullscreen", "--no-input-default-bindings"
]
video_player = ["mpv", "--no-config", "--keep-open=no", "--force-window=no", "--no-border", "--no-osc", "--fullscreen", "--no-input-default-bindings"]
default_engine = "ordoor"
[engines.geas] # Wages of War -> Gifts of Peace -> Geas
data_dir = "./WoW-CD"
palette = "WagesOfWar"
[engines.ordoor] # Chaos Gate -> Order Door -> Ordoor
data_dir = "./CG"
palette = "ChaosGate"
[engines.baps] # Soldiers At War -> Boys at Play -> Baps
data_dir = "./SaW"
palette = "SoldiersAtWar"
[engines.sl] # Squad Leader -> ??? -> ???
data_dir = "./SL"
palette = "ChaosGate" # may not be relevant?
[options]
play_movies = true
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?

View File

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

View File

@@ -392,8 +392,11 @@ well-aligned amount.
Investigation has so far suggested the following:
* `Cell[0]` seems related to doors and canisters. Observed:
* Nothing special: 0x38
* ???: 0x39
* Imperial crate: 0x28
* Door: 0xB8
* `Cell[1]` seems related to special placeables (but not triggers). Bitfield. Observed:
* 0x01: Reactor
* 0x20: Door or door lock?
@@ -408,12 +411,12 @@ Investigation has so far suggested the following:
* `Cell[7]` Object 2 (Right) Area (Sets/*.set lookup)
* `Cell[6]` Object 2 (Right) Sprite + active flag
* `Cell[9]` Object 3 (Center) Area (Sets/*.set lookup)
* `Cell[10]` Object 3 (Right) Sprite + active flag
* `Cell[11]` all 255?
* `Cell[10]` Object 3 (Center) Sprite + active flag
* `Cell[11]` all 255? Vehicle?
* `Cell[12]` all 0?
* `Cell[13]` all 0?
* `Cell[14]` all 0?
* `Cell[15]` shows squad positions, MP start positions, etc, as 0x04
* `Cell[15]` shows squad positions, MP start positions, etc, as 0x04. Bitfield?
Mapping the altar in Chapter01 to the map01 set suggests it's a palette entry
lookup, 0-indexed. `U` debug in WH40K_TD.exe says the cell's `Object 3-Center`
@@ -515,5 +518,285 @@ Around 001841A0: mission objectives!
00184240 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
```
Since all the files are exactly the same length uncompressed, I'm going to
assume these are all a fixed number of fixed-size records when looking into it.
Relative offsets from the start of the trailer, we have:
| Offset | Text |
| -------- | ---- |
| `0xEE` | Mania |
| `0x78A` | Dagon |
| `0xE26` | Nihasa |
| `0x14C2` | Samnu |
| `0x1b5e` | Bael |
| `0x2896` | Gigamen |
| `0x2f32` | Valefor |
| `0x35ce` | Baalberith |
| `0x3c6a` | Fenriz |
| `0x4306` | #Character |
| `0x49a2` | Apollyon |
So there are 1692 bytes between each name (the names probably don't come at the
start of each block, but it's still a useful stride). Presumably `#Character` is
a space for one of the player characters, while the others specify an NPC placed
on the map.
There's 56 of these records between the first and last name we see - `Ahpuch`.
Then there are a number of other strings that seem related to triggers / events,
including lots that say `NO FILE`. The first two are 96 bytes apart; from then
on they seem to be placed variably apart from each other; I've seen 96, 256, and
352 byte offsets.
At 0x20916 the mission objective is readable.
At 0x2092a the mission description is readable.
Generating another map with just 5 characters on it, things look different:
* Trailer size is 13543 bytes
* There are only 5 names
* There are none of the trigger/event strings
* Mission title is found at 0x2b93
* Mission briefing is found at 0x2c92
Since the trailer is a variable size, there must be a header that tells us how
many of each type of record to read. Peeking at the differences in `vbindiff`:
```
Chapter01.MAP.Trailer
0000 0000: 38 00 00 68 00 00 00 50 00 00 00 1A 00 00 00 14 8..h...P ........
0000 0010: 00 00 00 3A 00 00 00 00 38 25 00 04 00 00 00 00 ...:.... 8%......
0000 0020: 00 00 00 1A 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0040: 00 00 00 00 00 00 00 00 00 00 00 02 00 00 00 00 ........ ........
0000 0050: 00 00 00 32 00 00 00 00 00 00 00 00 00 00 00 00 ...2.... ........
TINYSQUAD.MAP.Trailer
0000 0000: 38 00 00 4B 00 00 00 3C 00 00 00 37 00 00 00 28 8..K...< ...7...(
0000 0010: 00 00 00 05 00 00 00 00 2B 3A 00 04 00 00 00 05 ........ +:......
0000 0020: 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0040: 00 00 00 00 00 00 00 00 00 00 00 02 00 00 00 00 ........ ........
0000 0050: 00 00 00 32 00 00 00 00 00 00 00 00 00 00 00 00 ...2.... ........
```
The size of the trailer for Chapter01 is 139,483 bytes, assuming it starts at
`0x163890`. However, things may be a lot more sensible if we drop 3 bytes off
the start of that to get the fields into little-endian alignment. Have I made a
maths error above somewhere? Is it some sort of alignment thing? Do those 3
bytes actually have meaning?
Ignoring them for now, here's a first guess at a header:
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| 0 | 4 | Map maximum X + 1 |
| 4 | 4 | Map maximum Y + 1 |
| 8 | 4 | Map minimum X |
| 12 | 4 | Map minimum Y |
| 16 | 4 | Number of character records |
| 20 | 4 | Padding? - invariant `00 00 00 00` |
| 24 | 2 | ??? - varies. Seems related to character/squad position? |
| 26 | 2 | ??? - invariant `00 04` |
| 28 | 4 | ??? - varies (0 vs 5) |
| 32 | 1 | Number of thingies |
| 33 | 3 | ???. With a Lord of Change on the map, only one byte works as thingies count |
| 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.
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?
Here's where we're at with the per-character data, going from the padding values
suggested above:
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| 0 | 178 | ??? |
| 178 | 1 | Character type |
| 179 | 80 | Character name |
| 259 | 1 | Weapon Skill |
| 260 | 1 | Ballistic Skill |
| 261 | 1 | Unknown |
| 262 | 1 | Leadership |
| 263 | 1 | Toughness |
| 264 | 1 | Strength |
| 265 | 1 | Action Points |
| 266 | 1 | Unknown |
| 267 | 1 | Unknown |
| 268 | 1 | Health |
| 269 | 495 | ??? |
| 764 | 1(?) | Squad number |
| 765 | 895 | ??? |
| 1660 | 1? | Orientation? Could also be `0x680`... |
| 1661 | 31 | ??? |
There's still a lot of bytes to dig through, but this allows me to load the
character names from Chapter01 correctly, with the exception of record 57 which
just contains `\x02` and is then null-terminated all the way through - but maybe
that's just a data thing.
How about their types? `HasAction.dat` lists numbers for character types, and
those show up immediately before the name. Going from the character type to the
animation group is not yet fully deciphered - squad leaders mess up a direct
correlation - but a fixed offset table allows me to draw the characters \o/.
Putting 8 characters onto a map and orienting them in the compass points, we see
numbers ranging from 0 to 7 at 0x67c and 0x680. Assuming this is the scheme
used, north is value 1, northeast value 2, and northwest value 0.
Given two characters of the same type, just in different locations, differing
values are seen at:
* `0x103 - 0x10c` (hodgepodge)
* `0x178 - 0x1be` (hodgepodge)
* `0x2fc` (0, 1) - squad number?
I can easily correlate certain bytes in the first range to various character
attributes. A few remain unset.
In Chapter01, picking a random character (Gorgon) and looking at his squadmates,
they are all in the same squad, and no other characters are in that squad, so it
looks pretty diagnostic to me. There's nothing in the UI to indicate the squad,
though.
Now let's look for position. In my 2-character map, they're at 65,50 and 70,55.
Within a character, I see those numbers repeated twice - around `0x1b{9,a}` and
`0x1b{d,e}`. This may be some kind of multiple-squares-taken-up thing.
Adding a (tall) Lord of Change to the map gave me `02 36 45 00 02 37 45`, which
doesn't quite match what my eyes are telling me for Z,Y,X. In addition, the data
immediately after this offset changed into a large number of coordinate-like
sets of values - far too many for it to actually be a bounding box. However, the
first one remains good as a position specifier.
Down in `0x679` (Chaos Sorcerer) or `0x68D` (Lord of Change), the map coords for
the *other* character appears, which is downright odd. For now, just use the
first-indexed value.
Thingies next: these aren't decoded at all yet, and the sizes seem to be
variable.
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| | | |
Finally, 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 |
This duplicates the information found in the `.TXT` files. No idea what the end
data is yet.
## Soldiers At War
All the above applies to Chaos Gate maps. Maps for Soldiers At War seem to have
a lot of similarities, but also some differences. For a start, the maps are a
variable size!
Starting with the header, given a tiny 26x20 generated map, the first 256 bytes
look like this:
```
00000000: 1500414d 425f4d41 50005041 52495300 ..AMB_MAP.PARIS.
00000010: 00000000 00000000 00000000 00000000 ................
00000020: 00000000 00000000 00000000 00000000 ................
00000030: 00000000 00000000 00000000 00000000 ................
00000040: 00000000 00000000 00000000 00000000 ................
00000050: 00000000 00000000 00000000 00000000 ................
00000060: 00000000 00000000 00000000 00000000 ................
00000070: 00000000 00000000 00000000 00000000 ................
00000080: 00000000 00000000 00001e00 45000100 ............E...
00000090: 1f004600 10010000 52000000 00001b00 ..F.....R.......
000000a0: 38000100 00000500 0a000001 00f0f9ff 8...............
000000b0: ffb60500 00000100 ff370a00 64006400 .........7..d.d.
000000c0: 08008501 00000000 00ff0000 1f008082 ................
000000d0: 01000000 0000ff00 001f0080 84010000 ................
000000e0: 000000ff 00001f00 00810100 00000000 ................
000000f0: ff00001f 00808301 00000000 00ff0000 ................
```
Almost everything we knew is out of the window, but a few things look familiar.
First, the header seems simplified down to just two recognisable-at-first-glance
fields: Magic bytes (now `\x15\x00AMV_MAP\x00`) and the set name, coming
immediately after.
Like Chaos Gate, all map files are the same size once uncompressed, but they are
smaller - at 1,214,559 bytes, they are 76% the size. This is quite significant.
We now have 13.3 bytes per voxel, rather than the 17.5 bytes per voxel that was
available to Chaos Gate. This means that the number of bytes *per cell* must be
reduced, in addition to the header (and trailer?) values.
Looking at data from 0x110, it seems to group naturally into 13-byte records:
```
$ xxd -s 0x110 -c 13 -l 65 -g 1 TINYMAP.MAP
00000110: 80 01 00 00 00 00 00 ff 00 00 1f 00 00 .............
0000011d: 85 01 00 00 00 00 00 ff 00 00 1f 00 00 .............
0000012a: 82 01 00 00 00 00 00 ff 00 00 1f 00 80 .............
00000137: 82 01 00 00 00 00 00 ff 00 00 1f 00 00 .............
00000144: 82 01 00 00 00 00 00 ff 00 00 1f 00 80 .............
```
It's a strange number. Chaos Gate cells group nicely on 16 bytes:
```
$ xxd -s 0x110 -c 16 -l 64 -g 1 Chapter01.MAP
00000110: 3f 00 00 00 83 01 00 00 00 00 00 ff 00 00 00 00 ?...............
00000120: 38 00 00 00 85 01 00 00 00 00 00 ff 00 00 00 00 8...............
00000130: 38 00 00 00 84 01 00 00 00 00 00 ff 00 00 00 00 8...............
00000140: 38 00 00 00 8a 01 00 00 00 00 00 ff 00 00 00 00 8...............
00000150: 38 00 00 00 83 01 00 00 00 00 00 ff 00 00 00 00 8...............
```
That grouping is very enticing, though. I feel strongly that it's the right
number.
Now we need to ask about start offset. Where is byte 0 of the per-cell data, and
do the 13 bytes it has line up neatly to the functions of some of the 16 bytes
seen in Chaos Gate?
I generated a `BIGGESTMAP` (130x100) to investigate. It's just grass, nothing
but grass, and 0xC0 is the first offset where it starts to look nicely grouped:
```
xxd -s 0xc0 -c 13 -l 260 -g 13 BIGGESTMAP.MAP
000000c0: 08 80 81 01 00 00 00 00 00 ff 00 00 1f .............
000000cd: 00 80 81 01 00 00 00 00 00 ff 00 00 1f .............
000000da: 00 00 81 01 00 00 00 00 00 ff 00 00 1f .............
000000e7: 00 00 85 01 00 00 00 00 00 ff 00 00 1f .............
# ...
```
This can be interpreted more or less the same way as the Chaos Gate maps now,
and the `soldiers-at-war` branch contains a hacked-up implementation that kind
of works \o/.
Does the same trailer apply? Seemingly not. Looking at `PARIS.MAP`, there's no
similarity at first glance.
However, I did manage to track down 4 32-bit ints inside the trailer, starting
at `0x121ad1`, which specify dimensions of the map, at least. Perhaps the
position has moved, but some of the data is the same? It's 3320 bytes into the
trailer.

View File

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

View File

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

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.

35
go.mod
View File

@@ -1,12 +1,33 @@
module code.ur.gs/lupine/ordoor
go 1.12
go 1.22.0
toolchain go1.23.2
require (
github.com/BurntSushi/toml v0.3.1
github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5
golang.org/x/exp v0.0.0-20200320212757-167ffe94c325 // indirect
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0 // indirect
golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae // indirect
github.com/BurntSushi/toml v1.4.0
github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065
github.com/hajimehoshi/ebiten/v2 v2.8.2
github.com/kr/text v0.2.0 // indirect
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/samuel/go-pcx v0.0.0-20210515040514-6a5ce4d132f7
github.com/stretchr/testify v1.9.0
golang.org/x/image v0.21.0
golang.org/x/sys v0.26.0 // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/gomobile v0.0.0-20241016134836-cc2e38a7c0ee // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/oto/v3 v3.3.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/jezek/xgb v1.1.1 // indirect
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sync v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

135
go.sum
View File

@@ -1,91 +1,48 @@
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/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc=
github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/hajimehoshi/bitmapfont v1.2.0/go.mod h1:h9QrPk6Ktb2neObTlAbma6Ini1xgMjbJ3w7ysmD7IOU=
github.com/hajimehoshi/ebiten v1.10.2 h1:PiJBY4Q4udip675T+Zqvb3NKMp1eyLWBelp660ZMrkQ=
github.com/hajimehoshi/ebiten v1.10.2/go.mod h1:i9dIEUf5/MuPtbK1/wHR0PB7ZtqhjOxxg+U1xfxapcY=
github.com/hajimehoshi/ebiten v1.10.5 h1:hVb3GJP4IDqOETifRmPg4xmURRgbIJoB9gQk+Jqe8Uk=
github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5 h1:hke9UdXY1YPfqjXG1bCSZnoVnfVBw9SzvmlrRn3dL3w=
github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5/go.mod h1:0SLvfr8iI2NxzpNB/olBM+dLN9Ur5a9szG13wOgQ0nQ=
github.com/hajimehoshi/go-mp3 v0.2.1 h1:DH4ns3cPv39n3cs8MPcAlWqPeAwLCK8iNgqvg0QBWI8=
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/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/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/gomobile v0.0.0-20241016134836-cc2e38a7c0ee h1:YoNt0DHeZ92kjR78SfyUn1yEf7KnBypOFlFZO14cJ6w=
github.com/ebitengine/gomobile v0.0.0-20241016134836-cc2e38a7c0ee/go.mod h1:ZDIonJlTRW7gahIn5dEXZtN4cM8Qwtlduob8cOCflmg=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE=
github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065 h1:7QVNyw2v9R1qOvbe9vfeVJWWKCSnd2Ap+8l8/CtG9LM=
github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065/go.mod h1:uN4GbWHfit2ByfOKQ4K6fuLy1/Os2eLynsIrDvjiDgM=
github.com/hajimehoshi/ebiten/v2 v2.8.2 h1:cvZ5d3LSVFzvcSZVGjTPyV43DzWzJWbwy1b+2V5zJPI=
github.com/hajimehoshi/ebiten/v2 v2.8.2/go.mod h1:SXx/whkvpfsavGo6lvZykprerakl+8Uo1X8d2U5aAnA=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
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/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
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-20200228211341-fcea875c7e85 h1:jqhIzSw5SQNkbu5hOGpgMHhkfXxrbsLJdkIRcX19gCY=
golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20200319221330-857350248e3d h1:1kJNg12kVM6Xid7xoFkhq/YJVU4NMTv5b3hJCfQnwjc=
golang.org/x/exp v0.0.0-20200319221330-857350248e3d/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20200320212757-167ffe94c325 h1:iPGJw87eUJvke9YLYKX0jIwLHiIrY/kXcFSgOpjav28=
golang.org/x/exp v0.0.0-20200320212757-167ffe94c325/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
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 h1:nZASbxDuz7CO3227BWCCf0MC6ynyvKh6eMDoLcNXAk0=
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0/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-20200317113312-5766fd39f98d h1:62ap6LNOjDU6uGmKXHJbSfciMoV+FeI1sRXx/pLDL44=
golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae h1:3tcmuaB7wwSZtelmiv479UjUB+vviwABz7a133ZwOKQ=
golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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-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 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0VBNqY/88RNnhSGYkrHaO0mmFGbVsc=
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg=
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/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-20210515040514-6a5ce4d132f7 h1:WhAiClm3vGzSl2EWdFsCFBEu2jEhHGa8qGsz4iIEpRc=
github.com/samuel/go-pcx v0.0.0-20210515040514-6a5ce4d132f7/go.mod h1:8ofl4LzpDayZKQZYbUyCDW41Y6lgVoO02ABp57OASxY=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

139
internal/assetstore/ani.go Normal file
View File

@@ -0,0 +1,139 @@
package assetstore
import (
"fmt"
"code.ur.gs/lupine/ordoor/internal/data"
"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 int, recId int, compass int) (*Animation, error) {
realIdx, err := a.AnimationsIndex()
if err != nil {
return nil, err
}
// FIXME: are we using the right value if we need to make this change?
if compass == 0 {
compass = 8
}
obj, err := a.AnimationsObject()
if err != nil {
return nil, err
}
group := realIdx.Groups[groupIdx]
if group.Spec.Count == 0 {
return &Animation{}, nil
}
var det *idx.Detail
for i, rec := range group.Records {
if /*int(rec.ActionID) == int(action) && */ int(rec.Compass) == compass {
det = &group.Details[i]
}
}
if det == nil {
return nil, fmt.Errorf("Couldn't find anim (%v %v %v)", groupIdx, recId, compass)
}
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
}
func (a *AssetStore) CharacterAnimation(ctype data.CharacterType, action data.AnimAction, compass int) (*Animation, error) {
ha, err := a.HasAction()
if err != nil {
return nil, err
}
if !ha.Check(ctype, action) {
return nil, fmt.Errorf("character %s: animation %s: not available", ctype, action)
}
// FIXME: we still need to be able to go from CTYPE to GROUP. In particular,
// squad leaders seem to be a modification on top of a previous group, which
// is a bit awkward. For now, hardcode it. How are captain modifiers stored?
group, ok := map[data.CharacterType]int{
data.CharacterTypeTactical: 1, // Has captain
data.CharacterTypeAssault: 3, // Has captain
data.CharacterTypeDevastator: 5,
data.CharacterTypeTerminator: 6, // Has captain
data.CharacterTypeApothecary: 8,
data.CharacterTypeTechmarine: 9,
data.CharacterTypeChaplain: 10,
data.CharacterTypeLibrarian: 11,
data.CharacterTypeCaptain: 12,
data.CharacterTypeChaosMarine: 13,
data.CharacterTypeChaosLord: 14,
data.CharacterTypeChaosChaplain: 15,
data.CharacterTypeChaosSorcerer: 16,
data.CharacterTypeChaosTerminator: 17,
data.CharacterTypeKhorneBerserker: 18,
data.CharacterTypeBloodThirster: 19, // This is a rotating thing?
data.CharacterTypeBloodLetter: 20,
data.CharacterTypeFleshHound: 21,
data.CharacterTypeLordOfChange: 22, // Another rotating thing?
data.CharacterTypeFlamer: 23,
data.CharacterTypePinkHorror: 24,
data.CharacterTypeBlueHorror: 25,
data.CharacterTypeChaosCultist: 26,
}[ctype]
if !ok {
return nil, fmt.Errorf("Unknown character type: %s", ctype)
}
return a.Animation(group, int(action), compass)
}

View File

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

View File

@@ -0,0 +1,100 @@
package assetstore
import (
"image"
"github.com/hajimehoshi/ebiten/v2"
)
// 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,91 @@
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 (a *AssetStore) HasAction() (*data.HasAction, error) {
if a.hasAction != nil {
return a.hasAction, nil
}
filename, err := a.lookup("HasAction", "dat", "Data")
if err != nil {
return nil, err
}
hasAction, err := data.LoadHasAction(filename)
if err != nil {
return nil, err
}
a.hasAction = hasAction
return hasAction, 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,39 @@
package assetstore
import (
"image"
"os"
"github.com/hajimehoshi/ebiten/v2"
_ "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 := ebiten.NewImageFromImage(rawImg)
a.images[name] = img
return img, nil
}

View File

@@ -1,9 +1,11 @@
package assetstore
import (
"fmt"
"image"
"log"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/maps"
)
@@ -46,12 +48,7 @@ func (a *AssetStore) Map(name string) (*Map, error) {
}
m := &Map{
Rect: image.Rect(
int(raw.MinWidth),
int(raw.MinLength),
int(raw.MaxWidth),
int(raw.MaxLength),
),
Rect: raw.Rect(),
assets: a,
raw: raw,
set: set,
@@ -64,8 +61,8 @@ func (a *AssetStore) Map(name string) (*Map, error) {
func (m *Map) LoadSprites() error {
// Eager load the sprites we use
for x := m.Rect.Min.X; x <= m.Rect.Max.X; x++ {
for y := m.Rect.Min.Y; y <= m.Rect.Max.Y; y++ {
for x := m.Rect.Min.X; x < m.Rect.Max.X; x++ {
for y := m.Rect.Min.Y; y < m.Rect.Max.Y; y++ {
for z := 0; z < maps.MaxHeight; z++ {
if _, err := m.SpritesForCell(x, y, z); err != nil {
return err
@@ -77,6 +74,11 @@ func (m *Map) LoadSprites() error {
return nil
}
// FIXME: get rid of this
func (m *Map) Cell(x, y, z int) *maps.Cell {
return m.raw.At(x, y, z)
}
// SpritesForCell returns the sprites needed to correctly render this cell.
// They should be rendered from first to last to get the correct ordering
func (m *Map) SpritesForCell(x, y, z int) ([]*Sprite, error) {
@@ -90,7 +92,7 @@ func (m *Map) SpritesForCell(x, y, z int) ([]*Sprite, error) {
obj, err := m.set.Object(ref.Index())
if err != nil {
return nil, err
return nil, fmt.Errorf("Failed to get object for %#+v: %v", ref, err)
}
sprite, err := obj.Sprite(ref.Sprite())
@@ -100,6 +102,27 @@ func (m *Map) SpritesForCell(x, y, z int) ([]*Sprite, error) {
sprites = append(sprites, sprite)
}
if chr := m.CharacterAt(x, y, z); chr != nil {
// Look up the correct animation, get the frame, boom shakalaka
anim, err := m.assets.CharacterAnimation(chr.Type, data.AnimActionNone, int(chr.Orientation))
if err != nil {
return nil, err
}
sprites = append(sprites, anim.Frames[0])
}
return sprites, nil
}
func (m *Map) CharacterAt(x, y, z int) *maps.Character {
// FIXME: don't iterate
for i, _ := range m.raw.Characters {
chr := &m.raw.Characters[i]
if chr.XPos == x && chr.YPos == y && z == 0 { // FIXME: sort out ZPos
return chr
}
}
return nil
}

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

@@ -0,0 +1,123 @@
package assetstore
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
"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

@@ -7,7 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/v2"
"code.ur.gs/lupine/ordoor/internal/data"
)
@@ -42,7 +42,7 @@ func (a *AssetStore) Object(name string) (*Object, error) {
}
log.Printf("Loading object %v", name)
filename, err := a.lookup(name, "obj", "Obj")
filename, err := a.lookup(name, "obj", "Obj", "spr")
if err != nil {
return nil, err
}
@@ -90,11 +90,25 @@ func (o *Object) LoadSprites() error {
return nil
}
func (o *Object) Sprites(start, count int) ([]*Sprite, error) {
out := make([]*Sprite, count)
for i := start; i < start+count; i++ {
sprite, err := o.Sprite(i)
if err != nil {
return nil, err
}
out[i-start] = sprite
}
return out, nil
}
func (o *Object) Sprite(idx int) (*Sprite, error) {
if sprite := o.sprites[idx]; sprite != nil {
return sprite, nil
}
log.Printf("Loading sprite %v:%v", o.raw.Name, idx)
if o.raw.Sprites[idx] == nil {
if err := o.raw.LoadSprite(idx); err != nil {
@@ -103,10 +117,7 @@ func (o *Object) Sprite(idx int) (*Sprite, error) {
}
raw := o.raw.Sprites[idx]
img, err := ebiten.NewImageFromImage(raw.ToImage(), ebiten.FilterDefault)
if err != nil {
return nil, err
}
img := ebiten.NewImageFromImage(raw.ToImage(o.assets.Palette))
rect := image.Rect(
int(raw.XOffset),

View File

@@ -4,8 +4,8 @@ import (
"log"
"os"
"github.com/hajimehoshi/ebiten/audio"
"github.com/hajimehoshi/ebiten/audio/vorbis"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/vorbis"
)
type Sound struct {
@@ -57,7 +57,7 @@ func (s *Sound) InfinitePlayer() (*audio.Player, error) {
return nil, err
}
infinite := audio.NewInfiniteLoop(decoder, decoder.Size())
infinite := audio.NewInfiniteLoop(decoder, decoder.Length())
return audio.NewPlayer(audio.CurrentContext(), infinite)
}

View File

@@ -1,21 +1,78 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
type WH40K struct {
DataDir string `toml:"data_dir"`
VideoPlayer []string `toml:"video_player"`
type Engine struct {
DataDir string `toml:"data_dir"`
Palette string `toml:"palette"`
}
// Things set in the options hash
// 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 {
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
_, err := toml.DecodeFile(filename, &out)
@@ -23,10 +80,74 @@ func Load(filename string) (*Config, error) {
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) DataFile(path string) string {
return filepath.Join(c.DataDir, path)
func (c *Config) HasUnsetOptions() bool {
var empty Options
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
}

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

@@ -0,0 +1,272 @@
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
AnimActionStandingReady 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
}
var (
aActions = map[AnimAction]string{
AnimActionNone: "None",
AnimActionAnim: "Anim",
AnimActionWalk: "Walk",
AnimActionExplosion: "Explosion",
AnimActionProjectile: "Projectile",
AnimActionSmoke: "Smoke",
AnimActionStandingShoot: "Standing Shoot",
AnimActionStandingDeath: "Standing Death",
AnimActionPain: "Pain",
AnimActionSpellFx1: "Spell FX 1",
AnimActionSpellFx2: "Spell FX 2",
AnimActionSpellFx3: "Spell FX 3",
AnimActionSpellFx4: "Spell FX 4",
AnimActionSpellFx5: "Spell FX 5",
AnimActionRun: "Run",
AnimActionCrouch: "Crouch",
AnimActionStand: "Stand",
AnimActionStandingReady: "Standing Ready",
AnimActionStandingUnready: "Standing Unready",
AnimActionCrouchingReady: "Crouching Ready",
AnimActionCrouchingUnready: "Crouching Unready",
AnimActionCrouchingShoot: "Crouching Shoot",
AnimActionStandingGrenade: "Standing Grenade",
AnimActionCrouchingGrenade: "Crouching Grenade",
AnimActionDrawMelee: "Draw Melee",
AnimActionSlash: "Slash",
AnimActionStab: "Stab",
AnimActionBlown: "Blown",
AnimActionCrouchingDeath: "Crouching Death",
AnimActionJump: "Jump",
AnimActionHeal: "Heal",
AnimActionTechWork: "Tech Work",
AnimActionCast: "Cast",
AnimActionShoot: "Shoot",
AnimActionDeath: "Death",
AnimActionFromWarp: "From Warp",
}
cTypes = map[CharacterType]string{
CharacterTypeTactical: "Tactical",
CharacterTypeAssault: "Assault",
CharacterTypeDevastator: "Devastator",
CharacterTypeTerminator: "Terminator",
CharacterTypeApothecary: "Apothecary",
CharacterTypeTechmarine: "Techmarine",
CharacterTypeChaplain: "Chaplain",
CharacterTypeLibrarian: "Librarian",
CharacterTypeCaptain: "Captain",
CharacterTypeChaosMarine: "Chaos Marine",
CharacterTypeChaosLord: "Chaos Lord",
CharacterTypeChaosChaplain: "Chaos Chaplain",
CharacterTypeChaosSorcerer: "Chaos Sorcerer",
CharacterTypeChaosTerminator: "Chaos Terminator",
CharacterTypeKhorneBerserker: "Knorne Berserker",
CharacterTypeBloodThirster: "Bloodthirster",
CharacterTypeBloodLetter: "Bloodletter",
CharacterTypeFleshHound: "Flesh Hound",
CharacterTypeLordOfChange: "Lord of Change",
CharacterTypeFlamer: "Flamer",
CharacterTypePinkHorror: "Pink Horror",
CharacterTypeBlueHorror: "Blue Horror",
CharacterTypeChaosCultist: "Cultist",
}
)
func (a AnimAction) String() string {
if str, ok := aActions[a]; ok {
return str
}
return "Unknown Action"
}
func (c CharacterType) String() string {
if str, ok := cTypes[c]; ok {
return str
}
return "Unknown Character"
}
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
}
// FIXME: Too slow
func (h *HasAction) Index(c CharacterType, requestedAction AnimAction) int {
for i, action := range h.Actions(c) {
if action == requestedAction {
return i
}
}
return -1
}
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
type I18n struct {
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
@@ -22,6 +22,15 @@ const (
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) {
f, err := os.Open(filename)
if err != nil {
@@ -32,7 +41,7 @@ func LoadI18n(filename string) (*I18n, error) {
out := &I18n{
Name: filepath.Base(filename),
mapping: make(map[int]string),
mapping: make(map[int]Entry),
}
scanner := bufio.NewScanner(f)
@@ -63,7 +72,12 @@ func LoadI18n(filename string) (*I18n, error) {
// 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 {
@@ -78,8 +92,14 @@ func (n *I18n) Len() int {
}
// 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 {
*out = str
*out = str.Text
}
}
func (n *I18n) ReplaceHelp(in int, out *string) {
if str, ok := n.mapping[in]; ok {
*out = str.Help
}
}

View File

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,330 @@
package flow
import (
"errors"
"fmt"
"log"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"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) SetScenario(scenario *scenario.Scenario) {
f.current = f.drivers[mainGame]
f.scenario = scenario
}
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 inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
f.scenario.SelectHighlightedCharacter()
// Now we need to update the info screens with data about the
// selected character. FIXME: oh, for data binding
f.selectedMainGameCharacter(f.scenario.SelectedCharacter())
}
}
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
}

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

@@ -0,0 +1,205 @@
package flow
import (
"code.ur.gs/lupine/ordoor/internal/maps"
)
// 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 buttons
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
}))
}
func (f *Flow) maybeSetErr(next func() error) {
if f.exit != nil {
return
}
f.exit = next()
}
func (f *Flow) selectedMainGameCharacter(chr *maps.Character) {
if chr == nil {
chr = &maps.Character{}
}
d := f.drivers[mainGame]
// 7.1 Portrait
f.maybeSetErr(func() error { return d.SetValue("7.2", chr.Name) }) // Name
// 7.3 doesn't exit
// 7.4 more button (ignore)
// 7.5 AP icon
// f.maybeSetErr(func() error { return d.SetValueInt("7.6", chr.ActionPoints)}) // AP meter
f.maybeSetErr(func() error { return d.SetValueInt("7.7", chr.ActionPoints) }) // AP value
// 7.8 armor icon
// 7.9 armor meter
f.maybeSetErr(func() error { return d.SetValueInt("7.10", chr.Armor) }) // armor value
// 7.11 health icon
// 7.12 health meter
f.maybeSetErr(func() error { return d.SetValueInt("7.13", chr.Health) }) // health value
// 7.14 action points status bar
// 7.15 armor status bar
// 7.16 health status bar
// 8.1 to 8.10 are hot spots
f.maybeSetErr(func() error { return d.SetValueInt("8.11", chr.ActionPoints) }) // AP
f.maybeSetErr(func() error { return d.SetValueInt("8.12", chr.Health) }) // Health
f.maybeSetErr(func() error { return d.SetValueInt("8.13", chr.Armor) }) // Armor
f.maybeSetErr(func() error { return d.SetValueInt("8.14", chr.BallisticSkill) }) // Ballistic Skill
f.maybeSetErr(func() error { return d.SetValueInt("8.15", chr.WeaponSkill) }) // Weapon Skill
f.maybeSetErr(func() error { return d.SetValueInt("8.16", chr.Strength) }) // Strength
f.maybeSetErr(func() error { return d.SetValueInt("8.17", chr.Toughness) }) // Toughness
// 8.18 Initiative
// 8.19 Attacks
f.maybeSetErr(func() error { return d.SetValueInt("8.20", chr.Leadership) }) // Leadership
}

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

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

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

@@ -0,0 +1,105 @@
package flow
import (
"log"
)
func (f *Flow) linkOptions() {
f.onClick(options, "2.8", f.setReturningDriver(options, kbd)) // Keyboard settings button
f.configureSlider(options, "2.9", h3Slider) // Resolution slider
f.configureSlider(options, "2.10", v10Slider) // Music volume slider
f.configureSlider(options, "2.11", v10Slider) // SFX volume slider
f.onClick(options, "2.12", f.acceptOptions) // OK button
f.onClick(options, "2.24", f.cancelOptions) // Cancel button
f.configureSlider(options, "2.26", h9Slider) // Unit speed slider
f.configureSlider(options, "2.27", h9Slider) // Animation speed slider
f.linkKeyboard()
}
// FIXME: exiting is a bit OTT. Perhaps display "save failed"?
func (f *Flow) acceptOptions() {
if err := f.optionsIntoConfig(); err != nil {
log.Printf("Saving options to config failed: %v", err)
f.exit = err
} else {
f.returnToLastDriverNow(options)
}
}
// FIXME: again, exiting is OTT. We're just resetting the state of
// the interface to the values in config.
func (f *Flow) cancelOptions() {
if err := f.configIntoOptions(); err != nil {
log.Printf("Saving options to config failed: %v", err)
f.exit = err
} else {
f.exit = f.returnToLastDriverNow(options)
}
}
func (f *Flow) configIntoOptions() error {
var err error
cfg := &f.config.Options
optionsUI := f.drivers[options]
try(optionsUI.SetValueBool("2.1", cfg.PlayMovies), &err)
try(optionsUI.SetValueBool("2.1", cfg.Animations), &err)
try(optionsUI.SetValueBool("2.3", cfg.PlayMusic), &err)
try(optionsUI.SetValueBool("2.4", cfg.CombatVoices), &err)
try(optionsUI.SetValueBool("2.5", cfg.ShowGrid), &err)
try(optionsUI.SetValueBool("2.6", cfg.ShowPaths), &err)
try(optionsUI.SetValueBool("2.7", cfg.PointSaving), &err)
try(optionsUI.SetValueInt("2.9", cfg.ResolutionIndex()), &err)
try(optionsUI.SetValueInt("2.10", cfg.MusicVolume), &err)
try(optionsUI.SetValueInt("2.11", cfg.SFXVolume), &err)
try(optionsUI.SetValueBool("2.25", cfg.AutoCutLevel), &err)
try(optionsUI.SetValueInt("2.26", cfg.UnitSpeed), &err)
try(optionsUI.SetValueInt("2.27", cfg.AnimSpeed), &err)
return err
}
func (f *Flow) optionsIntoConfig() error {
var resIdx int // needs handling manually
var err error
cfg := &f.config.Options
optionsUI := f.drivers[options]
try(optionsUI.ValueBool("2.1", &cfg.PlayMovies), &err)
try(optionsUI.ValueBool("2.2", &cfg.Animations), &err)
try(optionsUI.ValueBool("2.3", &cfg.PlayMusic), &err)
try(optionsUI.ValueBool("2.4", &cfg.CombatVoices), &err)
try(optionsUI.ValueBool("2.5", &cfg.ShowGrid), &err)
try(optionsUI.ValueBool("2.6", &cfg.ShowPaths), &err)
try(optionsUI.ValueBool("2.7", &cfg.PointSaving), &err)
try(optionsUI.ValueInt("2.9", &resIdx), &err)
try(optionsUI.ValueInt("2.10", &cfg.MusicVolume), &err)
try(optionsUI.ValueInt("2.11", &cfg.SFXVolume), &err)
try(optionsUI.ValueBool("2.25", &cfg.AutoCutLevel), &err)
try(optionsUI.ValueInt("2.26", &cfg.UnitSpeed), &err)
try(optionsUI.ValueInt("2.27", &cfg.AnimSpeed), &err)
if err != nil {
return err
}
cfg.SetResolutionIndex(resIdx)
if err := f.config.Save(); err != nil {
return err
}
return nil
}
func try(result error, into *error) {
if *into == nil {
*into = result
}
}

View File

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

View File

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

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,12 +5,15 @@ import (
"compress/gzip"
"encoding/binary"
"fmt"
"io"
"image"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"github.com/lunixbochs/struc"
"code.ur.gs/lupine/ordoor/internal/data"
)
var (
@@ -26,63 +29,135 @@ const (
CellSize = 16 // seems to be
cellDataOffset = 0x110 // tentatively
cellDataOffset = 0x110 // definitely
cellCount = MaxHeight * MaxLength * MaxWidth
)
type Header struct {
IsCampaignMap uint32 // Tentatively: 0 = no, 1 = yes
MinWidth uint32
MinLength uint32
MaxWidth uint32
MaxLength uint32
Unknown1 uint32
Unknown2 uint32
Unknown3 uint32
Unknown4 uint32
Magic [8]byte // "\x08\x00WHMAP\x00"
Unknown5 uint32
Unknown6 uint32
SetName [8]byte // Links to a filename in `/Sets/*.set`
// Need to investigate the rest of the header too
type GameMap struct {
// Main Header
IsCampaignMap bool `struc:"uint32"` // Tentatively: 0 = no, 1 = yes
MinWidth int `struc:"uint32"`
MinLength int `struc:"uint32"`
MaxWidth int `struc:"uint32"`
MaxLength int `struc:"uint32"`
Unknown1 int `struc:"uint32"`
Unknown2 int `struc:"uint32"`
Unknown3 int `struc:"uint32"`
Unknown4 int `struc:"uint32"`
Magic []byte `struc:"[8]byte"` // "\x08\x00WHMAP\x00"
Unknown5 int `struc:"uint32"`
Unknown6 int `struc:"uint32"`
SetName string `struc:"[8]byte"` // Links to a filename in `/Sets/*.set`
Padding []byte `struc:"[212]byte"`
// Per-cell data
NumCells int `struc:"skip"` // FIXME: We can't use []Cell below without this field
Cells []Cell `struc:"[]Cell,sizefrom=NumCells"`
// Trailer header
Discard1 [3]byte `struc:"[3]byte"` // First byte is size of trailer header?
TrailerMaxWidth int `struc:"uint32"`
TrailerMaxLength int `struc:"uint32"`
TrailerMinWidth int `struc:"uint32"`
TrailerMinLength int `struc:"uint32"`
NumCharacters int `struc:"uint32"`
TrailerUnknown1 int `struc:"uint32"`
TrailerUnknown2 int `struc:"uint16"`
TrailerUnknown3 int `struc:"uint16"`
TrailerUnknown4 int `struc:"uint32"`
NumThingies int `struc:"byte"`
TrailerUnknown5 []byte `struc:"[3]byte"`
Padding1 []byte `struc:"[20]byte"`
// FIXME: The rest is trash until Character & Thingy are worked out
Characters []Character `struc:"[]Character,sizefrom=NumCharacters"`
Thingies []Thingy `struc:"[]Thingy,sizefrom=NumThingies"`
Title string `struc:"[255]byte"`
Briefing string `struc:"[2048]byte"`
// Maybe? each contains either 0 or 1? Hard to say
TrailerUnknown6 []byte `struc:"[85]byte"`
}
func (h Header) Width() int {
return int(h.MaxWidth - h.MinWidth)
type Cell struct {
DoorAndCanisterRelated byte `struc:"byte"`
DoorLockAndReactorRelated byte `struc:"byte"`
Unknown2 byte `struc:"byte"`
Surface ObjRef
Left ObjRef
Right ObjRef
Center ObjRef
Unknown11 byte `struc:"byte"`
Unknown12 byte `struc:"byte"`
Unknown13 byte `struc:"byte"`
Unknown14 byte `struc:"byte"`
SquadRelated byte `struc:"byte"`
}
func (h Header) Length() int {
return int(h.MaxLength - h.MinLength)
type Character struct {
Unknown1 []byte `struc:"[178]byte"`
Type data.CharacterType `struc:"byte"`
Name string `struc:"[80]byte"`
// Attributes guessed by matching up numbers. Starts at 0x103
WeaponSkill int `struc:"byte"`
BallisticSkill int `struc:"byte"`
Unknown2 byte `struc:"byte"`
Leadership int `struc:"byte"`
Toughness int `struc:"byte"`
Strength int `struc:"byte"`
ActionPoints int `struc:"byte"`
Unknown3 byte `struc:"byte"`
Unknown4 byte `struc:"byte"`
Health int `struc:"byte"`
Unknown5 []byte `struc:"[91]byte"`
Armor int `struc:"byte"`
Unknown6 []byte `struc:"[84]byte"`
YPos int `struc:"byte"` // These are actually much more complicated
XPos int `struc:"byte"`
Unknown7 []byte `struc:"[317]byte"`
SquadNumber byte `struc:"byte"`
Unknown8 []byte `struc:"[895]byte"`
Orientation byte `struc:"byte"`
Unknown9 []byte `struc:"[31]byte"`
// TODO: each character may have a fixed number of subrecords for inventory
}
func (h Header) Height() int {
return MaxHeight
type Characters []Character
// TODO. These are triggers/reactors/etc.
type Thingy struct {
Unknown1 int `struc:"uint32"`
}
func (h Header) MapSetName() string {
idx := bytes.IndexByte(h.SetName[:], 0)
if idx < 0 {
idx = 8 // all 8 bytes are used
}
type Thingies []Thingy
return string(h.SetName[0:idx:idx])
func (g *GameMap) MapSetName() string {
return g.SetName
}
func (h Header) MapSetFilename() string {
return h.MapSetName() + ".set"
func (g *GameMap) MapSetFilename() string {
return g.MapSetName() + ".set"
}
type ObjRef struct {
AreaByte byte
SpriteAndFlagByte byte
AreaByte byte `struc:"byte"`
SpriteAndFlagByte byte `struc:"byte"`
}
// The index into a set palette to retrieve the object
func (o ObjRef) Index() int {
func (o *ObjRef) Index() int {
return int(o.AreaByte)
}
func (o ObjRef) Sprite() int {
func (o *ObjRef) Sprite() int {
// The top bit seems to be a flag of some kind
return int(o.SpriteAndFlagByte & 0x7f)
}
@@ -92,21 +167,6 @@ func (o ObjRef) IsActive() bool {
return (o.SpriteAndFlagByte & 0x80) == 0x80
}
type Cell struct {
DoorAndCanisterRelated byte
DoorLockAndReactorRelated byte
Unknown2 byte
Surface ObjRef
Left ObjRef
Right ObjRef
Center ObjRef
Unknown11 byte
Unknown12 byte
Unknown13 byte
Unknown14 byte
SquadRelated byte
}
func (c *Cell) At(n int) byte {
switch n {
case 0:
@@ -146,31 +206,27 @@ func (c *Cell) At(n int) byte {
return 0
}
// Cells is always a fixed size; use At to get a cell according to x,y,z
type Cells []Cell
func (c Cells) At(x, y, z int) Cell {
return c[(z*MaxLength*MaxWidth)+(y*MaxWidth)+x]
func (g *GameMap) At(x, y, z int) *Cell {
return &g.Cells[(z*MaxLength*MaxWidth)+(y*MaxWidth)+x]
}
func (h Header) Check() []error {
var out []error
if h.IsCampaignMap > 1 {
out = append(out, fmt.Errorf("Expected 0 or 1 for IsCampaignMap, got %v", h.IsCampaignMap))
func (g *GameMap) Check() error {
if bytes.Compare(expectedMagic, g.Magic) != 0 {
return fmt.Errorf("Unexpected magic value: %v", g.Magic)
}
if bytes.Compare(expectedMagic, h.Magic[:]) != 0 {
out = append(out, fmt.Errorf("Unexpected magic value: %v", h.Magic))
}
// TODO: other consistency checks
return out
return nil
}
type GameMap struct {
Header
Cells
// TODO: parse this into sections
Text string
func (m *GameMap) Rect() image.Rectangle {
return image.Rect(
int(m.MinWidth),
int(m.MinLength),
int(m.MaxWidth),
int(m.MaxLength),
)
}
// A game map contains a .txt and a .map. If they're in the same directory,
@@ -192,23 +248,17 @@ func LoadGameMap(prefix string) (*GameMap, error) {
return nil, fmt.Errorf("Couldn't find %s.{map,txt}, even ignoring case", prefix)
}
// A game map is composed of two files: .map and .txt
// A game map is composed of two files: .map and .txt. We ignore the text file,
// since the content is replicated in the map file.
func LoadGameMapByFiles(mapFile, txtFile string) (*GameMap, error) {
out, err := loadMapFile(mapFile)
if err != nil {
return nil, err
}
// TODO: load text and parse into sections
txt, err := ioutil.ReadFile(txtFile)
if err != nil {
if err := out.Check(); err != nil {
return nil, err
}
out.Text = string(txt)
for _, err := range out.Check() {
log.Printf("%s: %v", mapFile, err)
}
return out, nil
}
@@ -246,6 +296,7 @@ func LoadGameMaps(dir string) (map[string]*GameMap, error) {
func loadMapFile(filename string) (*GameMap, error) {
var out GameMap
out.NumCells = cellCount
mf, err := os.Open(filename)
if err != nil {
@@ -261,20 +312,46 @@ func loadMapFile(filename string) (*GameMap, error) {
defer zr.Close()
if err := binary.Read(zr, binary.LittleEndian, &out.Header); err != nil {
return nil, fmt.Errorf("Error parsing %s: %v", filename, err)
}
// no gzip.SeekReader, so discard unread header bytes for now
discardSize := int64(cellDataOffset - binary.Size(&out.Header))
if _, err := io.CopyN(ioutil.Discard, zr, discardSize); err != nil {
if err := struc.UnpackWithOrder(zr, &out, binary.LittleEndian); err != nil {
return nil, err
}
out.Cells = make(Cells, cellCount)
if err := binary.Read(zr, binary.LittleEndian, &out.Cells); err != nil {
return nil, fmt.Errorf("Error parsing cells for %s: %v", filename, err)
// Trim any trailing nulls off of the strings
nullTerminate(&out.SetName)
nullTerminate(&out.Title)
nullTerminate(&out.Briefing)
for i, _ := range out.Characters {
chr := &out.Characters[i]
nullTerminate(&chr.Name)
fmt.Printf("Character %v: %s\n", i, chr.String())
}
fmt.Printf("Mission Title: %q\n", out.Title)
fmt.Printf("Mission Briefing: %q\n", out.Briefing)
return &out, nil
}
func nullTerminate(s *string) {
sCpy := *s
idx := strings.Index(sCpy, "\x00")
if idx < 0 {
return
}
*s = sCpy[0:idx]
}
func (c *Character) String() string {
return fmt.Sprintf(
"squad=%v pos=(%v,%v) type=%q name=%q\n"+
"\t%3d %3d %3d %3d %3d\n\t%3d %3d ??? ??? %3d\n",
c.SquadNumber,
c.XPos, c.YPos,
c.Type.String(),
c.Name,
c.ActionPoints, c.Health, c.Armor, c.BallisticSkill, c.WeaponSkill,
c.Strength, c.Toughness /*c.Initiative, c.Attacks,*/, c.Leadership,
)
}

View File

@@ -2,6 +2,8 @@ package menus
import (
"fmt"
"image"
"image/color"
"io/ioutil"
"path/filepath"
"strconv"
@@ -10,30 +12,68 @@ import (
"code.ur.gs/lupine/ordoor/internal/util/asciiscan"
)
// MenuType tells us what sort of Group we have
type MenuType int
// SubMenuType tells us what sort of Record we have
type SubMenuType int
const (
TypeStatic = 0
TypeMenu = 1
TypeOverlay = 61
TypeMainButton = 228
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
SubTypeClickText SubMenuType = 60
SubTypeOverlay SubMenuType = 61
SubTypeHypertext SubMenuType = 70
SubTypeCheckbox SubMenuType = 91
SubTypeEditBox SubMenuType = 100
SubTypeInventorySelect SubMenuType = 110
SubTypeRadioButton SubMenuType = 120
SubTypeDropdownButton SubMenuType = 200
SubTypeComboBoxItem SubMenuType = 205
SubTypeAnimationSample SubMenuType = 220
SubTypeAnimationHover SubMenuType = 221 // FONTTYPE is animation speed. Only animate when hovered
SubTypeMainButton SubMenuType = 228
SubTypeSlider SubMenuType = 232
SubTypeStatusBar SubMenuType = 233
SubTypeListBoxUp SubMenuType = 400 // FIXME: these have multiple items in SUBMENUTYPE
SubTypeListBoxDown SubMenuType = 405
)
type Record struct {
Parent *Record
Children []*Record
// 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,
}
Id int
Type int
DrawType int
FontType int
Active bool
SpriteId []int
Share int
X int
Y int
Desc string
// 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",
}
// FIXME: turn these into first-class data
properties map[string]string
var TypeOverrides = map[string]SubMenuType{
// FIXME: These are put down as simple buttons, but it's a *lot* easier to
// understand them as list box buttons.
"configure_ultequip:7.5": SubTypeListBoxUp,
"configure_ultequip:7.6": SubTypeListBoxDown,
}
type Menu struct {
@@ -42,18 +82,71 @@ type Menu struct {
ObjectFiles []string
FontNames []string
// FIXME: turn these into first-class data
Properties map[string]string
// These are properties set in the menu header. We don't know what they're
// all for.
BackgroundColor color.Color
HypertextColor color.Color
FontType int
// The actual menu records. There are multiple top-level items. Submenus are
// only ever nested one deep.
Records []*Record
Groups []*Group
}
func LoadMenu(filename string) (*Menu, error) {
name := filepath.Base(filename)
// Group represents an element with a MENUTYPE. It is part of a Menu and may
// have children.
type Group struct {
Menu *Menu
Records []*Record
Properties
Type MenuType
}
type Record struct {
Menu *Menu
Group *Group
Properties
Type SubMenuType
}
type Properties struct {
Locator string // Not strictly a property. Set for tracking.
ID int
ObjectIdx int // Can be specified in MENUID, defaults to 0
Accelerator int
Active bool
Desc string
DrawType int
FontType int
Moveable bool
Share int
SoundType int
SpriteId []int
X int
Y int
// From i18n
Text string
Help string
}
func (p *Properties) Point() image.Point {
if p.X > 0 || p.Y > 0 {
return image.Pt(p.X, p.Y)
}
return image.Point{}
}
func LoadMenu(filename string, palette color.Palette) (*Menu, error) {
name := filepath.Base(filename)
name = strings.TrimSuffix(name, filepath.Ext(name))
name = strings.ToLower(name)
// FIXME: this needs turning into a real parser sometime
scanner, err := asciiscan.New(filename)
if err != nil {
return nil, err
@@ -61,66 +154,171 @@ func LoadMenu(filename string) (*Menu, error) {
defer scanner.Close()
var str string
var record *Record
section := 0
isProp := false
out := &Menu{
Name: name,
Properties: map[string]string{},
Name: name,
}
for {
str, err = scanner.ConsumeString()
if err != nil {
return nil, err
}
if err := loadObjects(out, scanner); err != nil {
return nil, err
}
// Whether the lines are properties or not alternate with each section,
// except the records use `*` as a separator
if section < 3 && isProp != asciiscan.IsProperty(str) {
section += 1
isProp = !isProp
}
if err := loadProperties(out, scanner, palette); err != nil {
return nil, err
}
if str == "~" {
break
}
if err := loadFonts(out, scanner); err != nil {
return nil, err
}
switch section {
case 0: // List of object files
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)
}
if err := loadRecords(filepath.Dir(filename), out, scanner); err != nil {
return nil, err
}
return out, nil
}
func LoadMenus(dir string) (map[string]*Menu, error) {
func loadObjects(menu *Menu, scanner *asciiscan.Scanner) error {
strs, err := scanner.ConsumeStringList()
if err != nil {
return err
}
menu.ObjectFiles = strs
return nil
}
func loadProperties(menu *Menu, scanner *asciiscan.Scanner, palette color.Palette) error {
for {
ok, err := scanner.PeekProperty()
if err != nil {
return err
}
if !ok {
break
}
k, v, err := scanner.ConsumeProperty()
if err != nil {
return err
}
vInt, err := strconv.Atoi(v) // All properties have been int
if err != nil {
return err
}
// DeBrief.mnu misspells these
parts := strings.SplitN(strings.ToUpper(k), " ", 3)
if len(parts) > 2 {
k = strings.Join(parts[0:2], " ")
}
switch strings.ToUpper(k) {
case "BACKGROUND COLOR":
menu.BackgroundColor = palette[vInt]
case "HYPERTEXT COLOR":
menu.HypertextColor = palette[vInt]
case "FONT TYPE":
menu.FontType = vInt
default:
return fmt.Errorf("Unhandled menu property in %v: %q=%q", menu.Name, k, v)
}
}
return nil
}
func loadFonts(menu *Menu, scanner *asciiscan.Scanner) error {
// FIXME: Can we just ignore NULL, or does the index matter?
strs, err := scanner.ConsumeStringList("NULL")
if err != nil {
return err
}
menu.FontNames = strs
return nil
}
func loadRecords(baseDir string, menu *Menu, scanner *asciiscan.Scanner) error {
// We build things up line by line in these variables
var group *Group
var record *Record
var properties *Properties
for {
str, err := scanner.ConsumeString()
if err != nil {
return err
}
if strings.HasPrefix(str, "$") {
subScanner, err := asciiscan.New(filepath.Join(baseDir, str[1:]))
if err != nil {
return err
}
err = loadRecords(baseDir, menu, subScanner)
subScanner.Close() // Don't keep this around for all of loadRecords
if err != nil {
return fmt.Errorf("Processing child %q: %v", str, err)
}
continue
}
if str == "*" {
if record != nil {
group.Records = append(group.Records, record)
record = nil
}
if group != nil {
menu.Groups = append(menu.Groups, group)
group = nil
}
continue // New group
}
if str == "~" {
break // THE END
}
k, v := asciiscan.ConsumeProperty(str)
switch strings.ToUpper(k) {
case "MENUID":
if group != nil {
menu.Groups = append(menu.Groups, group)
}
group = newGroup(menu, v)
properties = &group.Properties
case "SUBMENUID":
if record != nil {
group.Records = append(group.Records, record)
}
record = newRecord(group, v)
properties = &record.Properties
case "MENUTYPE":
group.setMenuType(v)
case "SUBMENUTYPE":
record.setSubMenuType(v)
default:
if err := properties.setProperty(k, v); err != nil {
return err
}
}
}
return nil
}
func LoadMenus(dir string, palette color.Palette) (map[string]*Menu, error) {
fis, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
@@ -138,7 +336,7 @@ func LoadMenus(dir string) (map[string]*Menu, error) {
continue
}
built, err := LoadMenu(filepath.Join(dir, relname))
built, err := LoadMenu(filepath.Join(dir, relname), palette)
if err != nil {
return nil, fmt.Errorf("%s: %v", filepath.Join(dir, relname), err)
}
@@ -149,102 +347,143 @@ func LoadMenus(dir string) (map[string]*Menu, error) {
return out, nil
}
func newRecord(parent *Record) *Record {
out := &Record{
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 (r *Record) SelectSprite(step int, pressed, focused bool) int {
switch r.Type {
case TypeStatic:
return r.SpriteId[0]
case TypeMenu:
return r.SpriteId[0] // Probably -1
case TypeOverlay:
return r.Share
case TypeMainButton:
// A main button has 4 states: unfocused, focused (animated), mousedown, disabled
if focused && pressed {
return r.Share + 1
} else if focused {
return r.SpriteId[0] + (step % r.DrawType)
}
// TODO: disabled
return r.Share
}
return -1
}
func setProperty(r *Record, k, v string) {
vSplit := strings.Split(v, ",")
vInt, _ := strconv.Atoi(v)
func listOfInts(s string) []int {
vSplit := strings.Split(s, ",")
vSplitInt := make([]int, len(vSplit))
for i, subV := range vSplit {
vSplitInt[i], _ = strconv.Atoi(subV)
}
switch k {
case "MENUID", "SUBMENUID":
r.Id = vInt
case "MENUTYPE", "SUBMENUTYPE":
r.Type = vInt
case "ACTIVE":
r.Active = (vInt != 0)
case "SPRITEID":
r.SpriteId = vSplitInt
case "X-CORD":
r.X = vInt
case "Y-CORD":
r.Y = vInt
case "DESC":
r.Desc = v
case "FONTTYPE":
r.FontType = vInt
case "DRAW TYPE":
r.DrawType = vInt
case "SHARE":
r.Share = vInt
default:
r.properties[k] = v
return vSplitInt
}
func newGroup(menu *Menu, idStr string) *Group {
out := &Group{Menu: menu}
// ObjectIdx can be specified in the MENUID. Only seen for .mni files
ints := listOfInts(idStr)
out.ID = ints[0]
if len(ints) > 1 {
out.ObjectIdx = ints[1]
}
out.Locator = fmt.Sprintf("%v:%v", menu.Name, out.ID)
return out
}
func newRecord(group *Group, idStr string) *Record {
out := &Record{Group: group}
out.ID, _ = strconv.Atoi(idStr) // FIXME: we're ignoring conversion errors here
out.ObjectIdx = group.ObjectIdx // FIXME: we shouldn't *copy* this
out.Locator = fmt.Sprintf("%v.%v", group.Locator, out.ID)
return out
}
func (g *Group) setMenuType(s string) {
v, _ := strconv.Atoi(s) // FIXME: conversion errors
g.Type = MenuType(v)
}
func (r *Record) setSubMenuType(s string) {
// FIXME: Type overrides shouldn't be necessary!
if override, ok := TypeOverrides[r.Locator]; ok {
r.Type = override
return
}
// FIXME: what are the other types here? Related to list boxes?
ints := listOfInts(s)
r.Type = SubMenuType(ints[0])
}
func (p *Properties) setProperty(k, v string) error {
ints := listOfInts(v)
vInt := ints[0]
asBool := (vInt != 0)
switch strings.ToUpper(k) {
case "ACCELERATOR":
p.Accelerator = vInt
case "ACTIVE":
p.Active = asBool
case "DESC":
p.Desc = v // Usually int, occasionally string
case "DRAW TYPE":
p.DrawType = vInt
case "FONTTYPE":
p.FontType = vInt
case "MOVEABLE":
p.Moveable = asBool
case "SOUNDTYPE":
p.SoundType = vInt
case "SPRITEID":
p.SpriteId = ints
case "X-CORD":
p.X = vInt
case "Y-CORD":
p.Y = vInt
case "SHARE":
p.Share = vInt
default:
return fmt.Errorf("Unknown property for %v: %v=%v", p.Locator, k, v)
}
return nil
}
type Replacer interface {
Replace(int, *string)
ReplaceText(int, *string)
ReplaceHelp(int, *string)
}
func (r *Record) Internationalize(replacer Replacer) {
id, err := strconv.Atoi(r.Desc)
if err == nil {
replacer.Replace(id, &r.Desc)
if override, ok := TextOverrides[r.Locator]; ok {
r.Text = override
return
}
for _, child := range r.Children {
child.Internationalize(replacer)
if override, ok := DescOverrides[r.Locator]; ok {
r.Desc = strconv.Itoa(override)
}
id, err := strconv.Atoi(r.Desc)
if err == nil {
replacer.ReplaceText(id, &r.Text)
replacer.ReplaceHelp(id, &r.Help)
} else {
r.Text = r.Desc // Sometimes it's a string like "EQUIPMENT"
}
}
func (m *Menu) Internationalize(replacer Replacer) {
for _, record := range m.Records {
record.Internationalize(replacer)
for _, group := range m.Groups {
for _, record := range group.Records {
record.Internationalize(replacer)
}
}
}
func (g *Group) Props() *Properties {
return &g.Properties
}
func (r *Record) Props() *Properties {
return &r.Properties
}
func (p *Properties) BaseSpriteID() int {
base := p.Share
// SpriteId takes precedence if present
if len(p.SpriteId) > 0 && p.SpriteId[0] >= 0 {
base = p.SpriteId[0]
}
return base
}

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
}

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

@@ -0,0 +1,209 @@
// package ordoor implements the full WH40K.EXE functionality, and is used from
// cmd/ordoor/main.go
//
// Entrypoint is Run()
package ordoor
import (
"fmt"
"log"
"sync"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/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
flowOnce sync.Once
// 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)
}
}
_ = audio.NewContext(48000)
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)
screen.DrawImage(pic, do)
return nil
}
if o.flow != nil {
return o.flow.Draw(screen)
}
return nil // Draw() may be called before Update()
}
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"
var (
Transparent = color.RGBA{R: 0, G: 0, B: 0, A: 0}
ColorPalette = color.Palette{
ChaosGatePalette = color.Palette{
Transparent,
color.RGBA{R: 128, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 128, B: 0, A: 255},
color.RGBA{R: 128, G: 128, B: 0, A: 255},
@@ -264,3 +263,7 @@ var (
color.RGBA{R: 255, G: 255, B: 255, A: 255},
}
)
func init() {
Palettes["ChaosGate"] = ChaosGatePalette
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,214 @@
package scenario
import (
"fmt"
"image"
"sort"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/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.highlightedCell = 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 := 0
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
}
}
}
// 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.highlightedCell.X), int(s.highlightedCell.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)
screen.DrawImage(spr.Image, &op)
x1, y1 := geo.Apply(0, 0)
ebitenutil.DebugPrintAt(
screen,
fmt.Sprintf("(%d,%d)", int(s.highlightedCell.X), int(s.highlightedCell.Y)),
int(x1),
int(y1),
)
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("Sprites: %v", counter), 0, 16)
/*
// 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 *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 {
*counter = *counter + 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)
screen.DrawImage(spr.Image, &op)
}
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,45 @@
package scenario
import (
"log"
"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.highlightedCell.X), int(s.highlightedCell.Y), 0)
return cell, CellPoint{IsoPt: s.highlightedCell, Z: 0}
}
func (s *Scenario) HighlightedCharacter() *maps.Character {
// FIXME: characters are always at zIdx 0 right now
return s.area.CharacterAt(int(s.highlightedCell.X), int(s.highlightedCell.Y), 0)
}
func (s *Scenario) SelectedCharacter() *maps.Character {
return s.selectedCharacter
}
func (s *Scenario) SelectHighlightedCharacter() {
chr := s.HighlightedCharacter()
log.Printf("Selected character %s", chr)
s.selectedCharacter = chr
}
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,53 @@
// package play takes a map and turns it into a playable scenario
package scenario
import (
"image"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/maps"
)
type Scenario struct {
area *assetstore.Map
specials *assetstore.Object
tick int
turn int
highlightedCell IsoPt
selectedCharacter *maps.Character
// All these must be modified by user actions somehow.
// TODO: extract into the idea of a viewport passed to Update / Draw somehow?
// Or have a separater Drawer for the Scenario?
Viewpoint image.Point // Top-left of the screen
ZIdx int // Currently-viewed Z index
Zoom float64 // Zoom level to set
}
func NewScenario(assets *assetstore.AssetStore, name string) (*Scenario, error) {
area, err := assets.Map(name)
if err != nil {
return nil, err
}
specials, err := assets.Object("specials") // FIXME: should this be hardcoded?
if err != nil {
return nil, err
}
// Eager load sprites. TODO: do we really want to do this?
//if err := area.LoadSprites(); err != nil {
// return nil, fmt.Errorf("Eager-loading sprites failed: %v", err)
//}
out := &Scenario{
area: area,
specials: specials,
Viewpoint: image.Pt(0, 3000), // FIXME: haxxx
Zoom: 1.0,
}
return out, nil
}

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

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

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

@@ -0,0 +1,19 @@
package ui
import (
"github.com/hajimehoshi/ebiten/v2"
)
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
}

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

@@ -0,0 +1,269 @@
package ui
import (
"fmt"
"image"
"runtime/debug"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/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)
screen.DrawImage(region.image, &do)
}
}
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)
}
}
}

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

@@ -0,0 +1,202 @@
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:
_, widget, err = d.buildDoorHotspot(r.Props())
case menus.SubTypeClickText:
_, widget, err = d.buildClickText(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/v2"
)
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.str = ""
if len(l.strings) > l.offset+i {
ni.label.str = 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,340 @@
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, possibly settable
type label struct {
locator string
align AlignMode
rect image.Rectangle
font *assetstore.Font
valueImpl
}
// 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
}
func (d *Driver) buildClickText(p *menus.Properties) (*noninteractive, *Widget, error) {
ni, err := d.buildNoninteractive(p)
if err != nil {
return nil, nil, err
}
fnt := d.menu.Font(p.FontType/10 - 1)
// FIXME: is this always right? Seems to make sense for Main.mnu
ni.label = &label{
locator: ni.locator,
font: fnt,
rect: ni.rect, // We will be centered by default
// Starts with no text. The text specified in the menu is hovertext
}
widget := &Widget{
Locator: ni.locator,
Active: p.Active,
ownClickables: []clickable{ni},
ownPaintables: []paintable{ni},
ownValueables: []valueable{ni.label},
}
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
valueImpl: valueImpl{str: 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)
}
func (l *label) id() string {
return l.locator
}
// 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.str)
// 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.str {
var sprite *assetstore.Sprite
if glyph, err := l.font.Glyph(r); err != nil {
if glyph, err := l.font.Glyph('?'); err != nil {
log.Printf("FIXME: ignoring glyph %v", r)
continue
} else {
sprite = glyph
}
} else {
sprite = glyph
}
out = append(out, oneRegion(pt, sprite.Image)...)
pt.X += sprite.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

@@ -5,46 +5,65 @@ import (
"fmt"
"log"
"os"
"runtime/debug"
"runtime/pprof"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/ebitenutil"
"github.com/hajimehoshi/ebiten/inpututil"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
type Game interface {
Update(screenX, screenY int) error
Draw(*ebiten.Image) error
}
type CustomCursor interface {
// The cursor draw operation
Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error)
}
var (
screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
)
// TODO: move all scaling into Window, so drivers only need to cope with one
// coordinate space. This will allow us to draw custom mouse cursors in the
// window, rather than in the driver.
type Window struct {
Title string
KeyUpHandlers map[ebiten.Key]func()
MouseWheelHandler func(float64, float64)
MouseClickHandler func()
// User-provided update actions
updateFn func() error
drawFn func(*ebiten.Image) error
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(title string) (*Window, error) {
ebiten.SetRunnableInBackground(true)
func NewWindow(game Game, title string, xRes int, yRes int) (*Window, error) {
return &Window{
Title: title,
Title: title,
debug: true,
firstRun: true,
game: game,
xRes: xRes,
yRes: yRes,
WhileKeyDownHandlers: make(map[ebiten.Key]func()),
KeyUpHandlers: make(map[ebiten.Key]func()),
debug: true,
firstRun: true,
}, nil
}
@@ -53,21 +72,65 @@ func (w *Window) OnKeyUp(key ebiten.Key, f func()) {
w.KeyUpHandlers[key] = f
}
func (w *Window) WhileKeyDown(key ebiten.Key, f func()) {
w.WhileKeyDownHandlers[key] = f
}
func (w *Window) OnMouseWheel(f func(x, y float64)) {
w.MouseWheelHandler = f
}
func (w *Window) run(screen *ebiten.Image) error {
if w.firstRun {
ebiten.SetScreenScale(*screenScale)
w.firstRun = false
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
}
if err := w.updateFn(); err != nil {
cursor, op, err := cIface.Cursor()
if err != nil {
return err
}
// Process keys
// Hide the system cursor if we have a custom one
if cursor == nil {
ebiten.SetCursorMode(ebiten.CursorModeVisible)
return nil
}
ebiten.SetCursorMode(ebiten.CursorModeHidden)
screen.DrawImage(cursor, op)
return nil
}
func (w *Window) Update() (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)
}
}()
// FIXME: remove need for update generally
if err := w.game.Update(w.xRes, w.yRes); err != nil {
return err
}
// Process keys.
// FIXME: : should this happen before or after update?
// TODO: efficient set operations
for key, cb := range w.KeyUpHandlers {
@@ -76,6 +139,12 @@ func (w *Window) run(screen *ebiten.Image) error {
}
}
for key, cb := range w.WhileKeyDownHandlers {
if ebiten.IsKeyPressed(key) {
cb()
}
}
if w.MouseWheelHandler != nil {
x, y := ebiten.Wheel()
if x != 0 || y != 0 {
@@ -83,28 +152,32 @@ func (w *Window) run(screen *ebiten.Image) error {
}
}
if !ebiten.IsDrawingSkipped() {
if err := w.drawFn(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)
if w.MouseClickHandler != nil {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
w.MouseClickHandler()
}
}
return nil
}
func (w *Window) Draw(screen *ebiten.Image) {
w.game.Draw(screen)
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
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(updateFn func() error, drawFn func(*ebiten.Image) error) error {
w.updateFn = updateFn
w.drawFn = drawFn
func (w *Window) Run() error {
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
@@ -117,5 +190,7 @@ func (w *Window) Run(updateFn func() error, drawFn func(*ebiten.Image) error) er
defer pprof.StopCPUProfile()
}
return ebiten.Run(w.run, *winX, *winY, 1, w.Title) // Native game resolution: 640x480
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 (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
@@ -15,6 +16,9 @@ var hashComment = []byte("#")
type Scanner struct {
bufio *bufio.Scanner
closer io.Closer
// If we've peeked, there will be items here
buffered []string
}
func New(filename string) (*Scanner, error) {
@@ -38,6 +42,13 @@ func (s *Scanner) Close() error {
}
func (s *Scanner) ConsumeString() (string, error) {
if len(s.buffered) > 0 {
out, buffered := s.buffered[0], s.buffered[1:]
s.buffered = buffered
return out, nil
}
for s.bufio.Scan() {
line := s.bufio.Bytes()
@@ -68,15 +79,41 @@ func ConsumeProperty(s string) (string, string) {
}
parts := strings.SplitN(s, ":", 2)
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
// Peek ahead in the input stream to see if the next line might be a property
// (contain a colon character).
// Check to see if the line looks like a property (contains a colon character).
func IsProperty(s string) bool {
return strings.Contains(s, ":")
}
// Checks if the next line might be a property, without reading it
func (s *Scanner) PeekProperty() (bool, error) {
str, err := s.ConsumeString()
if err != nil {
return false, err
}
s.buffered = append(s.buffered, str)
return IsProperty(str), nil
}
func (s *Scanner) ConsumeProperty() (string, string, error) {
str, err := s.ConsumeString()
if err != nil {
return "", "", err
}
if !IsProperty(str) {
return "", "", fmt.Errorf("Not a property: %q", str)
}
k, v := ConsumeProperty(str)
return k, v, nil
}
func (s *Scanner) ConsumeInt() (int, error) {
str, err := s.ConsumeString()
if err != nil {
@@ -86,6 +123,48 @@ func (s *Scanner) ConsumeInt() (int, error) {
return strconv.Atoi(str)
}
func (s *Scanner) ConsumeBool() (bool, error) {
integer, err := s.ConsumeInt()
if err != nil {
return false, err
}
return (integer > 0), nil
}
// Reads a list of non-property lines, skipping any that match the given strings
func (s *Scanner) ConsumeStringList(skip ...string) ([]string, error) {
skipper := make(map[string]bool, len(skip))
for _, str := range skip {
skipper[str] = true
}
var out []string
for {
isProp, err := s.PeekProperty()
if err != nil {
return nil, err
}
// The object list is terminated by the first property
if isProp {
break
}
str, err := s.ConsumeString()
if err != nil {
return nil, err
}
if !skipper[str] {
out = append(out, str)
}
}
return out, nil
}
func (s *Scanner) ConsumeIntPtr(to *int) error {
val, err := s.ConsumeInt()
if err != nil {
@@ -96,6 +175,16 @@ func (s *Scanner) ConsumeIntPtr(to *int) error {
return nil
}
func (s *Scanner) ConsumeBoolPtr(to *bool) error {
val, err := s.ConsumeBool()
if err != nil {
return err
}
*to = val
return nil
}
func (s *Scanner) ConsumeIntPtrs(ptrs ...*int) error {
for _, ptr := range ptrs {
if err := s.ConsumeIntPtr(ptr); err != nil {
@@ -105,3 +194,13 @@ func (s *Scanner) ConsumeIntPtrs(ptrs ...*int) error {
return nil
}
func (s *Scanner) ConsumeBoolPtrs(ptrs ...*bool) error {
for _, ptr := range ptrs {
if err := s.ConsumeBoolPtr(ptr); err != nil {
return err
}
}
return nil
}

View File

@@ -1,35 +0,0 @@
package wh40k
import (
"log"
"os/exec"
)
func (w *WH40K) PlayVideo(name string, skippable bool) {
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:])
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 (w *WH40K) PlayUnskippableVideo(name string) {
w.PlayVideo(name, false)
}
func (w *WH40K) PlaySkippableVideo(name string) {
w.PlayVideo(name, true)
}

View File

@@ -1,31 +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"
"code.ur.gs/lupine/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.PlaySkippableVideo("LOGOS")
wh40k.PlaySkippableVideo("movie1")
return nil
}

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

View File

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

Binary file not shown.

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

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

View File

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