Compare commits
102 Commits
bcaf3d9b58
...
main
Author | SHA1 | Date | |
---|---|---|---|
55c2232e08 | |||
ac4675fa2c | |||
5f7654d267 | |||
89888ce004 | |||
16767da6f1 | |||
c5b80ed8bc | |||
891edecc60 | |||
85979834c8 | |||
96dbb297cd | |||
92fa0fc5d6 | |||
c5e6abb798 | |||
5df050b4ef | |||
4d336b9189 | |||
3b7cfb6ecc | |||
7677c30572 | |||
eac6017c2c | |||
f971ba320c | |||
cf624cc77b | |||
65bae80d40 | |||
e8e9811b5d | |||
a6fdbaef2b | |||
0bf8233cd1 | |||
c2cbf1d95d | |||
54fe95239e | |||
63d3ee0ed6 | |||
5c869fc33c | |||
4358951e15 | |||
250a6033c8 | |||
f64af717b7 | |||
3866ee07a8 | |||
c1268e8d57 | |||
59baf20c35 | |||
cf58be6a20 | |||
14fdab72a0 | |||
c7a2fa80e7 | |||
def40a1ee2 | |||
48d098134e | |||
597e346869 | |||
eea5dea98a | |||
04bdf3e352 | |||
9d0750d134 | |||
1f4bfc771c | |||
c058f651dc | |||
9be93b6091 | |||
f8828c95bd | |||
903ddba2ac | |||
b191ba2a94 | |||
6e70ddcb60 | |||
1e141a2fb9 | |||
4df6be4fb1 | |||
4fe9a75d69 | |||
2b83ce4f7f | |||
b690c763bb | |||
beebfda3ba | |||
87c0aae54b | |||
32fd9f9aa9 | |||
80c65f68ca | |||
acb7882549 | |||
e2ad8f61c1 | |||
2f65cd312a | |||
26c976353f | |||
82d3849402 | |||
786d261f98 | |||
dc131939f4 | |||
76bf8438b0 | |||
5f8606377a | |||
0025daf8dd | |||
f3fea83173 | |||
bb3ddc4896 | |||
d99a5b9ec3 | |||
fd73f03aa5 | |||
df1f116b3d | |||
aa43011e8d | |||
8ce24ce5f8 | |||
7935f78acc | |||
31da40e772 | |||
c1925697c9 | |||
2ae3611d7f | |||
7586b90f8a | |||
b5a722eef0 | |||
27fbccdc5f | |||
c090fd32e9 | |||
316db89148 | |||
79bfab7d6b | |||
e4ce932324 | |||
a0fd653c24 | |||
3d3a55af9d | |||
4eb4b6e69f | |||
7824396c24 | |||
b986359047 | |||
d376d9850c | |||
20ad9ae6f8 | |||
69971b2825 | |||
bcee07e8f7 | |||
c67ee206cd | |||
971b3178d6 | |||
0adbfaa573 | |||
cfa56a0e12 | |||
d4d8a50ce4 | |||
3cb32b8962 | |||
ba7c06e5fd | |||
bfe9fbdf7d |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -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
|
||||
|
45
Makefile
45
Makefile
@@ -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
192
README.md
@@ -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:
|
||||
|
||||

|
||||
|
||||
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/)
|
||||
|
@@ -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
27
cmd/ordoor/main.go
Normal 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)
|
||||
}
|
@@ -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
177
cmd/view-ani/main.go
Normal 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
117
cmd/view-font/main.go
Normal 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)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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() {
|
||||
|
@@ -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() {
|
||||
|
@@ -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)
|
||||
}
|
@@ -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
|
||||
|
@@ -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
402
doc/formats/ani.md
Normal 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?
|
@@ -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`
|
||||
|
@@ -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.
|
||||
|
@@ -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:
|
||||
|
||||

|
||||
|
||||
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`.
|
||||
|
@@ -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
21
doc/formats/sound.md
Normal 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
35
go.mod
@@ -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
135
go.sum
@@ -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
139
internal/assetstore/ani.go
Normal 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)
|
||||
}
|
@@ -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 {
|
||||
|
100
internal/assetstore/cursor.go
Normal file
100
internal/assetstore/cursor.go
Normal 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
|
||||
}
|
91
internal/assetstore/data.go
Normal file
91
internal/assetstore/data.go
Normal 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
|
||||
}
|
97
internal/assetstore/font.go
Normal file
97
internal/assetstore/font.go
Normal 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
|
||||
}
|
29
internal/assetstore/i18n.go
Normal file
29
internal/assetstore/i18n.go
Normal 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
|
||||
}
|
39
internal/assetstore/image.go
Normal file
39
internal/assetstore/image.go
Normal 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
|
||||
}
|
@@ -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
123
internal/assetstore/menu.go
Normal 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
|
||||
}
|
@@ -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),
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
272
internal/data/has_action.go
Normal 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("")
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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
38
internal/flow/bridge.go
Normal 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
20
internal/flow/choices.go
Normal 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
8
internal/flow/credits.go
Normal 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
145
internal/flow/drivers.go
Normal 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
330
internal/flow/flow.go
Normal 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
25
internal/flow/keyboard.go
Normal 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")()
|
||||
}
|
||||
}
|
6
internal/flow/load_game.go
Normal file
6
internal/flow/load_game.go
Normal 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
205
internal/flow/main_game.go
Normal 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
79
internal/flow/new_game.go
Normal 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
105
internal/flow/options.go
Normal 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
|
||||
}
|
||||
}
|
10
internal/flow/save_game.go
Normal file
10
internal/flow/save_game.go
Normal 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
|
||||
}
|
@@ -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
109
internal/idx/idx.go
Normal 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
|
||||
}
|
@@ -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,
|
||||
)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
20
internal/ordoor/display.go
Normal file
20
internal/ordoor/display.go
Normal 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
209
internal/ordoor/ordoor.go
Normal 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
35
internal/ordoor/videos.go
Normal 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)
|
||||
}
|
@@ -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
|
||||
}
|
20
internal/palettes/palettes.go
Normal file
20
internal/palettes/palettes.go
Normal 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
|
||||
}
|
269
internal/palettes/soldiers_at_war.go
Normal file
269
internal/palettes/soldiers_at_war.go
Normal 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
|
||||
}
|
269
internal/palettes/wages_of_war.go
Normal file
269
internal/palettes/wages_of_war.go
Normal 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
214
internal/scenario/draw.go
Normal 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,
|
||||
}
|
||||
}
|
21
internal/scenario/draw_test.go
Normal file
21
internal/scenario/draw_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
45
internal/scenario/manage.go
Normal file
45
internal/scenario/manage.go
Normal 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
|
||||
}
|
53
internal/scenario/scenario.go
Normal file
53
internal/scenario/scenario.go
Normal 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
93
internal/ship/ship.go
Normal 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
19
internal/ui/animation.go
Normal 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
165
internal/ui/buttons.go
Normal 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
41
internal/ui/dialogues.go
Normal 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
269
internal/ui/driver.go
Normal 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
43
internal/ui/events.go
Normal 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
202
internal/ui/group.go
Normal 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
149
internal/ui/interfaces.go
Normal 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
|
||||
}
|
53
internal/ui/inventory_select.go
Normal file
53
internal/ui/inventory_select.go
Normal 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
186
internal/ui/list_box.go
Normal 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
|
||||
}
|
340
internal/ui/noninteractive.go
Normal file
340
internal/ui/noninteractive.go
Normal 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
263
internal/ui/selectors.go
Normal 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
128
internal/ui/value.go
Normal 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
142
internal/ui/widget.go
Normal 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
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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
|
@@ -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 |
@@ -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
|
Binary file not shown.
@@ -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
45
scripts/palette-from-pcx
Executable 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
|
@@ -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
|
||||
|
Reference in New Issue
Block a user