test world: gallery of all 253 figures and objects of every world as static display strats (cross-world import, idle animation, no ai), packed rows per world with coloured posts, animal pen removed
This commit is contained in:
1 parent
6a76cac69f
commit
d030c9c6c7
15 files changed
+1053
-175
No files matched your search
@@ -82,7 +82,7 @@ The following list is generated from the program, so it always matches your Llam
|
||||
|
||||
#### Test World
|
||||
|
||||
Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every object in the game, an animal enclosure and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab.
|
||||
Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every character and object model of all worlds (standing still, grouped by world) and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab.
|
||||
|
||||
*Id* `testworld`
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,116 @@
|
||||
# Test World (`testworld`, LEVELS)
|
||||
|
||||
Package `mods/testworld`: `TestWorldMod` (writes `wads/T1L9M001.wad` + loading picture `T191.COR`),
|
||||
`TestWorld` (the level script), `Gallery` (all-worlds model gallery), `Layout` (row packing),
|
||||
`JumpLab` (separate jump-measuring level, not used by the mod). No options in the launcher; the
|
||||
script options below are for tests and tools (`ViewerDemo testworld`, `TestWorld.buildWad(…, opts)`).
|
||||
|
||||
| Script option | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `gallery` | `all` | `none`, or comma-separated original type names (`TipoVillage,Spider`) |
|
||||
| `start` | `plaza` | `plaza`, `rowN` (middle of the aisle in front of gallery row N), `pathN` (its west end), `parkour`, `pk1`..`pk5`, `goal`, `goal2` |
|
||||
| `door` | `1,9,1` | exit parameters (tribe, world, chapter of the next level) |
|
||||
|
||||
## Layout (2026-11-07 rework)
|
||||
|
||||
Island (x 11..43.5, z 9.5..≈34) + strip under the parkour end. Start plaza x 15..21, z 12..19; the
|
||||
parkour along z = 15.5 to the goal platform with the exit is unchanged. The animal enclosure and
|
||||
viewing podium are gone. South of the plaza the **gallery**: rows along +x (x 16.6..43), one block of
|
||||
rows per world (1..8, then the front end), figures before objects, ordered by catalogue category
|
||||
(characters, player models, enemies, animals, other figures; collectibles, checkpoints, exits,
|
||||
mechanisms …). Every item stands on a 0.08 high stone pedestal sized to its model (min 0.3 × 0.35,
|
||||
gap 0.15); in front of every row a 0.75 wide sand aisle. Row posts at x 15.05 in the world colour;
|
||||
the first row of a world carries one small block per world number (front end: white, none).
|
||||
The island grows southwards with the gallery (12 rows, z 20.5..≈33).
|
||||
|
||||

|
||||
|
||||
## Gallery: every model of the game, without AI
|
||||
|
||||
`Gallery.build(wads)` scans the level WADs (t1l1m001…t1l8m006, t6l6m001/2) in order. For each strat
|
||||
type of a level's MAP (first level that places it wins, names deduplicated):
|
||||
|
||||
1. model = the first `address model; setModel` of the type's code (`StratModels.modelOf`, follows
|
||||
spawn stubs); otherwise the first one in any unit of its import closure (types that show their
|
||||
model through sub-strats: RisingColumns, FlockOfBirds, spawners …) – not for cameras, triggers
|
||||
and cut scenes. Types with their own model are handled first, so they win step 3.
|
||||
2. skinned models also get an animation set: the first one the type's code addresses that has the
|
||||
model's bone count (bone matrices, no vertex deltas), else any such set of the level.
|
||||
3. "look" = SHA-256 of the model bytes with every face texture replaced by its pixels, plus the
|
||||
animation bytes; a type that looks exactly like an earlier one is dropped (TipoLab = TipoCatacombs …).
|
||||
4. A **display strat** is compiled with `StratCompiler` and appended as its own unit to the
|
||||
*source* level's STPC:
|
||||
```
|
||||
strat GxTipoVillage {
|
||||
setModel(model:17)
|
||||
animPlay(anim:60) // skinned models only
|
||||
loop {
|
||||
if (!self.animPlaying) { animPlay(anim:60) }
|
||||
yield
|
||||
}
|
||||
}
|
||||
```
|
||||
No collision, physics, waypoints, events, texts or sounds – it cannot move, attack, talk,
|
||||
trigger anything or delete itself. Type fields in MAP: 1 variable, 0 slots, type flags 0, no
|
||||
parameters, no waypoints (nothing for the engine's "no waypoints → remove" checks to find).
|
||||
5. `mergeInto(base)` imports all display entries with `StpcMerge.merge` exactly like the editor's
|
||||
cross-world import: models, animation sets and textures come along (identical ones shared), the
|
||||
tiny units are appended, the base's own code is relocated (MAP strat codes remapped), TEXT gets
|
||||
the new pages/entries. One unplaced prototype record per display type is added to the base MAP so
|
||||
`LevelBuilder` knows the types (`l.strat("GxTipoVillage", …)`); the builder replaces all records.
|
||||
|
||||
Result (German CD): **253 entries**, merge +5.95 MB (227 models, 96 animation sets, 93 texture
|
||||
pages → 111 of 127, 3126 texture entries, 0 sounds/texts); STPC 10.1 MB, WAD 14.6 MB. Scan + merge
|
||||
≈ 10 s (mod apply).
|
||||
|
||||
| World | figures | objects | total |
|
||||
|---|---|---|---|
|
||||
| 1 Village | 23 | 27 | 50 |
|
||||
| 2 Jungle | 11 | 7 | 18 |
|
||||
| 3 River | 30 | 6 | 36 |
|
||||
| 4 Jungle day | 13 | 9 | 22 |
|
||||
| 5 Mountain | 7 | 11 | 18 |
|
||||
| 6 City | 13 | 17 | 30 |
|
||||
| 7 Catacombs | 7 | 19 | 26 |
|
||||
| 8 Lab / end | 30 | 20 | 50 |
|
||||
| front end | 3 | 0 | 3 |
|
||||
|
||||
Not shown (81 of 334 type names): 52 without a model of their own (cameras, cut-scene/dialogue triggers,
|
||||
force/current boxes, sound/particle effects, LevelDoor (its door is level geometry), Secret,
|
||||
CheatTeleport, AppearingPlatforms*, MouthPlatform …), 28 that look exactly like an earlier entry,
|
||||
1 skinned model without a usable animation (EndGameGuardsBottle). `TestWorld.notShown()` lists them
|
||||
with the reason.
|
||||
|
||||
## Engine facts found on the way
|
||||
|
||||
* **Strats are only drawn near the player.** Objects farther than ≈12.5 units from the llama are
|
||||
not rendered (they still exist) – walking along the gallery reveals the rows; a free camera (mod
|
||||
`freecam`) far away from the llama shows empty pedestals.
|
||||
* Strat heap: `FUN_0041ae20` allocates in u16-sized word blocks from a heap initialised with
|
||||
`(short)0x800000 − 3` = 65533 words (≈256 KB) for the strat pool (0x154 bytes × (poolExtra+30)),
|
||||
collision blocks and per-strat variables – plenty for the gallery (≈280 strats).
|
||||
|
||||
## Verified in game (apptest :26, 2026-11-07, host load ≈30)
|
||||
|
||||
Mods `testworld` (+ `freecam` with `freeze=false`, noclip F9 for the walk): level loads (no crash),
|
||||
all rows walked twice (254- and final 253-entry build), figures stand still and play their idle animation in place (screenshot
|
||||
diff: only limbs change), no dialogue, cut scene or attack, the llama walks through them. The
|
||||
game stayed responsive (no freeze); frame rate not measurable at that host load. Over the
|
||||
south edge the llama falls into the sea and respawns at the plaza checkpoint as before.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Tests
|
||||
|
||||
* `GalleryTest`: every entry is placed once, has a non-empty model (skinned = figure, posed with an
|
||||
animation), its unit only uses `debugName address setModel animPlay pushProp not jz jmpYield end`,
|
||||
type flags 0 / no parameters / no waypoints, merged code decodes without errors; rows do not
|
||||
overlap; `gallery=none` imports nothing, `gallery=a,b,x` places a and b and reports x.
|
||||
* `TestWorldGoldenTest`: SHA-256 of the test world (default and `start=parkour`) pins the current
|
||||
output (no Python counterpart any more), 253 gallery objects; jump lab and loading picture still
|
||||
compared with the Python tools.
|
||||
* `TestWorldExampleTest` / `examples/testwelt.llamalevel` keep the **original** test world (enclosure,
|
||||
base-level gallery, `TestWorldBrushes` = port of levels/testwelt.py): the brush project's build is
|
||||
compared with the level builder's WAD of the same script instead of the mod's output.
|
||||
@@ -0,0 +1,496 @@
|
||||
package com.eliasmueller.llamamod.mods.testworld;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.render.StratModels;
|
||||
import com.eliasmueller.llamamod.editor.tools.catalog.ObjectCatalog;
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Model;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Vertex;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.TextMerge;
|
||||
import com.eliasmueller.llamamod.format.strat.StratAssembler;
|
||||
import com.eliasmueller.llamamod.format.strat.StratCompiler;
|
||||
import com.eliasmueller.llamamod.format.strat.StratListing;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Gallery of every object model of the game for the test world. For each strat type of every
|
||||
* level (first level that places it) that shows a model, a "display" strat is written into that
|
||||
* level's STPC: it only sets the model, plays one animation set in a loop (skinned models) and
|
||||
* yields – no movement, collision, events, texts or sounds. These display types are then imported
|
||||
* into the base level with {@link StpcMerge} like the editor's cross-world import (models,
|
||||
* animations, textures are copied; only the tiny display units come along, not the original
|
||||
* behaviour). Types whose model and animation look exactly like an earlier entry's are dropped.
|
||||
*/
|
||||
public final class Gallery {
|
||||
/** Prefix of the display types' names ("Gx" + original type name). */
|
||||
public static final String PREFIX = "Gx";
|
||||
|
||||
/** Levels scanned, in gallery order: worlds 1..8, then the front-end levels. */
|
||||
static List<String> levels(Function<String, byte[]> wads) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (int w = 1; w <= 8; w++) {
|
||||
for (int c = 1; c <= 9; c++) {
|
||||
String rel = String.format("wads/t1l%dm%03d.wad", w, c);
|
||||
if (wads.apply(rel) != null) {
|
||||
out.add(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String fe : new String[] {"wads/t6l6m001.wad", "wads/t6l6m002.wad"}) {
|
||||
if (wads.apply(fe) != null) {
|
||||
out.add(fe);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One gallery entry. world 1..8 (0 = front end), kind: figure (skinned, animated) or object
|
||||
* (rigid); w/h/d: posed model size in model units; code: entry of the display type in the
|
||||
* merged STPC.
|
||||
*/
|
||||
public record Entry(
|
||||
String type, String level, int world, String category, boolean figure, double w, double h, double d) {
|
||||
public String displayName() {
|
||||
return PREFIX + type;
|
||||
}
|
||||
}
|
||||
|
||||
/** A type left out and why. */
|
||||
public record Skip(String type, String level, String reason) {}
|
||||
|
||||
/** Scan result: entries in gallery order and the skipped types. */
|
||||
public record Scan(List<Entry> entries, List<Skip> skipped) {}
|
||||
|
||||
/** Base level with the display types merged in. */
|
||||
public record Merged(byte[] wad, Map<String, Integer> codes, StpcMerge.Stats stats) {}
|
||||
|
||||
private record Pending(Entry entry, String source, int code, String key) {}
|
||||
|
||||
private record SourceLevel(String rel, Level level, byte[] stpc, StpcMerge.Analysis analysis) {}
|
||||
|
||||
private final Function<String, byte[]> wads;
|
||||
private final List<Pending> pending = new ArrayList<>();
|
||||
private final Map<String, SourceLevel> sources = new LinkedHashMap<>();
|
||||
private final List<Skip> skipped = new ArrayList<>();
|
||||
|
||||
private Gallery(Function<String, byte[]> wads) {
|
||||
this.wads = wads;
|
||||
}
|
||||
|
||||
/** Scans the CD and imports the display types into base. */
|
||||
public static Gallery build(Function<String, byte[]> wads) {
|
||||
Gallery g = new Gallery(wads);
|
||||
Set<String> seenNames = new HashSet<>();
|
||||
Map<String, String> seenLooks = new HashMap<>();
|
||||
for (String rel : levels(wads)) {
|
||||
g.scanLevel(rel, seenNames, seenLooks);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
public Scan scan() {
|
||||
List<Entry> es = new ArrayList<>();
|
||||
for (Pending p : pending) {
|
||||
es.add(p.entry);
|
||||
}
|
||||
return new Scan(es, List.copyOf(skipped));
|
||||
}
|
||||
|
||||
private static int worldOf(String rel) {
|
||||
String f = rel.substring(rel.lastIndexOf('/') + 1);
|
||||
return f.startsWith("t1l") ? f.charAt(3) - '0' : 0;
|
||||
}
|
||||
|
||||
private static String base(String rel) {
|
||||
String f = rel.substring(rel.lastIndexOf('/') + 1);
|
||||
return f.substring(0, f.length() - 4);
|
||||
}
|
||||
|
||||
private void scanLevel(String rel, Set<String> seenNames, Map<String, String> seenLooks) {
|
||||
Level lv = Level.parse(wads.apply(rel));
|
||||
byte[] st = lv.wad().require("STPC");
|
||||
StpcImage img = new StpcImage(st, lv.wfpc());
|
||||
TreeSet<Integer> roots = new TreeSet<>();
|
||||
Map<Integer, String> first = new LinkedHashMap<>();
|
||||
for (MapChunk.Strat s : lv.map().strats) {
|
||||
roots.add(s.code());
|
||||
first.putIfAbsent(s.code(), Strats.nameOr(st, s.code(), img.codeStart, "?"));
|
||||
}
|
||||
StpcMerge.Analysis an = new StpcMerge.Analysis(img, roots);
|
||||
StratModels sm = new StratModels(st, lv.wfpc());
|
||||
TextMerge.Textures tex = TextMerge.Textures.of(lv.wad().require("TEXT"));
|
||||
String lvBase = base(rel);
|
||||
List<String> sources = new ArrayList<>();
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
List<String> keys = new ArrayList<>();
|
||||
// types that set their own model first, so they win the look deduplication against
|
||||
// spawners and cameras that only show a sub-strat's model
|
||||
List<Map.Entry<Integer, String>> order = new ArrayList<>();
|
||||
for (var e : first.entrySet()) {
|
||||
if (sm.modelOf(e.getKey()) >= 0) {
|
||||
order.add(e);
|
||||
}
|
||||
}
|
||||
for (var e : first.entrySet()) {
|
||||
if (sm.modelOf(e.getKey()) < 0) {
|
||||
order.add(e);
|
||||
}
|
||||
}
|
||||
for (var e : order) {
|
||||
String name = e.getValue().replace("(spawn)", "");
|
||||
if (!name.matches("[A-Za-z_][A-Za-z0-9_]*") || !seenNames.add(name)) {
|
||||
continue;
|
||||
}
|
||||
int code = e.getKey();
|
||||
int model = sm.modelOf(code);
|
||||
if (model < 0) {
|
||||
String cat = ObjectCatalog.category(name);
|
||||
boolean logic = cat.equals("camera") || cat.equals("trigger") || name.matches("(?i).*cutscene.*");
|
||||
model = logic ? -1 : firstModel(an, code);
|
||||
}
|
||||
if (model < 0) {
|
||||
skipped.add(new Skip(name, lvBase, "no model of its own (logic, trigger, camera, sound or effect)"));
|
||||
continue;
|
||||
}
|
||||
Model m = sm.models.get(model);
|
||||
boolean skinned = StratModels.skinned(m);
|
||||
int anim = -1;
|
||||
if (skinned) {
|
||||
anim = animFor(an, sm, code, m);
|
||||
if (anim < 0) {
|
||||
skipped.add(new Skip(name, lvBase, "skinned model without a matching animation"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (m.faces.isEmpty()) {
|
||||
skipped.add(new Skip(name, lvBase, "empty model"));
|
||||
continue;
|
||||
}
|
||||
String look = look(img, tex, model, anim);
|
||||
String dup = seenLooks.putIfAbsent(look, name);
|
||||
if (dup != null) {
|
||||
skipped.add(new Skip(name, lvBase, "looks exactly like " + dup));
|
||||
continue;
|
||||
}
|
||||
sources.add(source(name, model, anim));
|
||||
String cat = ObjectCatalog.category(name);
|
||||
entries.add(new Entry(name, lvBase, worldOf(rel), cat, skinned, 0, 0, 0));
|
||||
keys.add(look);
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// append the display units to the source level's code region
|
||||
byte[] d = st;
|
||||
List<Integer> codes = new ArrayList<>();
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
StpcImage cur = new StpcImage(d, lv.wfpc());
|
||||
int base = d.length + 4;
|
||||
StratAssembler.Result r =
|
||||
StratAssembler.assemble(StratCompiler.compile(sources.get(i)), StratListing.layout(cur, base));
|
||||
byte[] words = r.bytes();
|
||||
byte[] nd = Arrays.copyOf(d, d.length + 4 + words.length);
|
||||
putI32(nd, d.length, words.length / 4);
|
||||
System.arraycopy(words, 0, nd, d.length + 4, words.length);
|
||||
putI32(nd, cur.codeStart, Bytes.i32(nd, cur.codeStart) + 1);
|
||||
d = nd;
|
||||
codes.add(base + r.labels().get(entries.get(i).displayName()));
|
||||
}
|
||||
TreeSet<Integer> roots2 = new TreeSet<>(roots);
|
||||
roots2.addAll(codes);
|
||||
StpcImage img2 = new StpcImage(d, lv.wfpc());
|
||||
StpcMerge.Analysis an2 = new StpcMerge.Analysis(img2, roots2);
|
||||
if (!an2.code.errors.isEmpty()) {
|
||||
throw new IllegalStateException(rel + ": display units do not decode: " + an2.code.errors.get(0));
|
||||
}
|
||||
StratModels sm2 = new StratModels(d, lv.wfpc());
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
Entry e = entries.get(i);
|
||||
int code = codes.get(i);
|
||||
int mi = sm2.modelOf(code);
|
||||
Model m = sm2.models.get(mi);
|
||||
Model posed = e.figure() ? sm2.posed(code, mi) : m;
|
||||
double[] b = bounds(posed == null ? m : posed);
|
||||
Entry sized = new Entry(
|
||||
e.type(), e.level(), e.world(), e.category(), e.figure(), b[3] - b[0], b[4] - b[1], b[5] - b[2]);
|
||||
pending.add(new Pending(sized, rel, code, keys.get(i)));
|
||||
}
|
||||
sources.clear();
|
||||
this.sources.put(rel, new SourceLevel(rel, lv, d, an2));
|
||||
}
|
||||
|
||||
/** Display strat: model, looping animation (skinned models), nothing else. */
|
||||
static String source(String name, int model, int anim) {
|
||||
StringBuilder b = new StringBuilder();
|
||||
b.append("strat ").append(PREFIX).append(name).append(" {\n");
|
||||
b.append(" setModel(model:").append(model).append(")\n");
|
||||
if (anim >= 0) {
|
||||
b.append(" animPlay(anim:").append(anim).append(")\n");
|
||||
}
|
||||
b.append(" loop {\n");
|
||||
if (anim >= 0) {
|
||||
b.append(" if (!self.animPlaying) {\n");
|
||||
b.append(" animPlay(anim:").append(anim).append(")\n");
|
||||
b.append(" }\n");
|
||||
}
|
||||
b.append(" yield\n }\n}\n");
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/** First model set by any unit the type reaches (types that show their model through sub-strats). */
|
||||
private static int firstModel(StpcMerge.Analysis an, int code) {
|
||||
StpcMerge.Closure c = StpcMerge.closure(an, code, null);
|
||||
int eu = an.img.unitOf(code);
|
||||
List<Integer> units = new ArrayList<>();
|
||||
units.add(eu);
|
||||
for (int u : c.units()) {
|
||||
if (u != eu) {
|
||||
units.add(u);
|
||||
}
|
||||
}
|
||||
for (int u : units) {
|
||||
for (StratCode.Site s : an.code.addrSites) {
|
||||
if (an.img.unitOf(s.offset()) == u && an.img.modelAt(s.value()) >= 0) {
|
||||
int next = s.offset() + 4;
|
||||
if (next + 4 <= an.img.data.length && (Bytes.u32L(an.img.data, next) & 0xffff) == 0x54) {
|
||||
return an.img.modelAt(s.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Animation set for a skinned model: first one of the type's code with the model's bone count. */
|
||||
private static int animFor(StpcMerge.Analysis an, StratModels sm, int code, Model m) {
|
||||
int bones = m.extra.length / 2;
|
||||
StpcMerge.Closure c = StpcMerge.closure(an, code, null);
|
||||
int eu = an.img.unitOf(code);
|
||||
List<Integer> units = new ArrayList<>();
|
||||
units.add(eu);
|
||||
for (int u : c.units()) {
|
||||
if (u != eu) {
|
||||
units.add(u);
|
||||
}
|
||||
}
|
||||
for (int u : units) {
|
||||
for (StratCode.Site s : an.code.addrSites) {
|
||||
if (an.img.unitOf(s.offset()) != u || s.offset() < code && u == eu) {
|
||||
continue;
|
||||
}
|
||||
int a = an.img.animAt(s.value());
|
||||
if (a >= 0 && usable(an.img.data, s.value(), bones)) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int a = 0; a < an.img.anims.size(); a++) {
|
||||
if (usable(an.img.data, an.img.anims.get(a).start(), bones)) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Bone-matrix animation set (no vertex deltas) with frames for the bone count. */
|
||||
private static boolean usable(byte[] d, int h, int bones) {
|
||||
return Bytes.u32(d, h + 24) == bones && Bytes.u32(d, h + 16) == 0 && Bytes.u32(d, h + 8) > 0;
|
||||
}
|
||||
|
||||
/** Identity of the look: model bytes with face textures replaced by their pixels, plus the animation bytes. */
|
||||
private static String look(StpcImage img, TextMerge.Textures tex, int model, int anim) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
StpcImage.Span sp = img.models.get(model);
|
||||
byte[] b = Arrays.copyOfRange(img.data, sp.start(), sp.end());
|
||||
Map<Integer, byte[]> texHash = new HashMap<>();
|
||||
for (int o : faceTexOffsets(img.data, sp.start())) {
|
||||
int t = Bytes.u16(img.data, o);
|
||||
b[o - sp.start()] = 0;
|
||||
b[o - sp.start() + 1] = 0;
|
||||
md.update(texHash.computeIfAbsent(t, k -> texturePixels(tex, k)));
|
||||
}
|
||||
md.update(b);
|
||||
if (anim >= 0) {
|
||||
StpcImage.Span a = img.anims.get(anim);
|
||||
md.update(img.data, a.start(), a.size());
|
||||
}
|
||||
return java.util.HexFormat.of().formatHex(md.digest());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] texturePixels(TextMerge.Textures tex, int t) {
|
||||
if (t < 0 || t >= tex.entries.size()) {
|
||||
return new byte[] {(byte) t};
|
||||
}
|
||||
TextChunk.TextureEntry e = tex.entries.get(t);
|
||||
if (e.page() < 0 || e.page() >= tex.pages.size()) {
|
||||
return new byte[] {(byte) t};
|
||||
}
|
||||
TextChunk.Page p = tex.pages.get(e.page());
|
||||
ByteArrayOutputStream o = new ByteArrayOutputStream();
|
||||
o.write(p.flags);
|
||||
int u0 = Math.min(e.u0(), e.u1()), u1 = Math.max(e.u0(), e.u1());
|
||||
int v0 = Math.min(e.v0(), e.v1()), v1 = Math.max(e.v0(), e.v1());
|
||||
for (int v = v0; v <= v1 && v < p.h; v++) {
|
||||
for (int u = u0; u <= u1 && u < p.w; u++) {
|
||||
int px = p.pixels[v * p.w + u];
|
||||
o.write(px);
|
||||
o.write(px >> 8);
|
||||
}
|
||||
}
|
||||
return o.toByteArray();
|
||||
}
|
||||
|
||||
/** Offsets of the u16 texture fields of a model's faces (STPC model header layout). */
|
||||
private static int[] faceTexOffsets(byte[] d, int h) {
|
||||
int nv = Bytes.u16(d, h + 0x6c), nf = Bytes.u16(d, h + 0x6e), nb = Bytes.u16(d, h + 0x86);
|
||||
int f0 = h + 0x8c + nb * 2 + nv * 0x18;
|
||||
int[] out = new int[nf];
|
||||
for (int i = 0; i < nf; i++) {
|
||||
out[i] = f0 + i * 0x1c + 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void putI32(byte[] d, int o, int v) {
|
||||
ByteBuffer.wrap(d, o, 4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(v);
|
||||
}
|
||||
|
||||
private static double[] bounds(Model m) {
|
||||
double[] r = {1e9, 1e9, 1e9, -1e9, -1e9, -1e9};
|
||||
for (Vertex v : m.verts) {
|
||||
r[0] = Math.min(r[0], v.x());
|
||||
r[1] = Math.min(r[1], v.y());
|
||||
r[2] = Math.min(r[2], v.z());
|
||||
r[3] = Math.max(r[3], v.x());
|
||||
r[4] = Math.max(r[4], v.y());
|
||||
r[5] = Math.max(r[5], v.z());
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the display types of the given entries into the base level: STPC, TEXT (new pages and
|
||||
* entries), MAP strat codes relocated, plus one unplaced prototype record per display type so
|
||||
* the level builder knows it.
|
||||
*/
|
||||
public Merged mergeInto(byte[] baseWad, Set<String> types) {
|
||||
Level lv = Level.parse(baseWad);
|
||||
Wad wad = lv.wad();
|
||||
byte[] st = wad.require("STPC");
|
||||
StpcImage ti = new StpcImage(st, lv.wfpc());
|
||||
TreeSet<Integer> roots = new TreeSet<>();
|
||||
for (MapChunk.Strat s : lv.map().strats) {
|
||||
roots.add(s.code());
|
||||
}
|
||||
StpcMerge.Analysis tgt = new StpcMerge.Analysis(ti, roots);
|
||||
byte[] text = wad.require("TEXT");
|
||||
TextMerge.Textures tex = TextMerge.Textures.of(text);
|
||||
int origTail = 8 + 8 * tex.entries.size();
|
||||
for (TextMerge.Anim a : tex.anims) {
|
||||
origTail += 3 + a.data().length;
|
||||
}
|
||||
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
|
||||
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
|
||||
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
|
||||
Map<String, StpcMerge.Source> srcs = new HashMap<>();
|
||||
List<StpcMerge.Request> reqs = new ArrayList<>();
|
||||
List<Pending> used = new ArrayList<>();
|
||||
for (Pending p : pending) {
|
||||
if (!types.contains(p.entry.type())) {
|
||||
continue;
|
||||
}
|
||||
StpcMerge.Source s = srcs.computeIfAbsent(p.source, k -> {
|
||||
SourceLevel sl = sources.get(k);
|
||||
Wad w = sl.level.wad();
|
||||
return new StpcMerge.Source(
|
||||
base(k),
|
||||
sl.analysis,
|
||||
TextMerge.Textures.of(w.require("TEXT")),
|
||||
SoundMerge.parse(w.get("SMPC")),
|
||||
w.get("LGPC") == null ? null : DialogMerge.Texts.parse(w.get("LGPC")),
|
||||
DialogMerge.Speech.parse(w.get("SRPC")));
|
||||
});
|
||||
reqs.add(new StpcMerge.Request(s, p.code));
|
||||
used.add(p);
|
||||
}
|
||||
StpcMerge.Result res = StpcMerge.merge(tgt, reqs, new StpcMerge.Target(tex, snd, texts, speech));
|
||||
if (res.stats().addedSamples() != 0 || res.stats().addedTexts() != 0) {
|
||||
throw new IllegalStateException("display types must not need sounds or texts");
|
||||
}
|
||||
Map<String, Integer> codes = new LinkedHashMap<>();
|
||||
MapChunk map = lv.map();
|
||||
List<MapChunk.Strat> recs = new ArrayList<>();
|
||||
for (MapChunk.Strat s : map.strats) {
|
||||
int[] a = s.toArray();
|
||||
a[6] = res.target().map(s.code());
|
||||
recs.add(MapChunk.Strat.of(a));
|
||||
}
|
||||
for (int i = 0; i < used.size(); i++) {
|
||||
int code = res.entries().get(reqs.get(i));
|
||||
codes.put(used.get(i).entry.type(), code);
|
||||
// rx ry rz x y z code nParams paramIndex nVars slots typeFlags extra waypoint zero flags last
|
||||
recs.add(MapChunk.Strat.of(new int[] {0, 0, 0, 0, 0, 0, code, 0, -1, 1, 0, 0, 0, -1, 0, 0, 0}));
|
||||
}
|
||||
map.strats.clear();
|
||||
map.strats.addAll(recs);
|
||||
Wad out = new Wad(wad.chunks());
|
||||
out.set("STPC", res.stpc());
|
||||
out.set("MAP", map.build());
|
||||
out.set("TEXT", text(text, tex, origTail));
|
||||
return new Merged(out.toBytes(), codes, res.stats());
|
||||
}
|
||||
|
||||
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
|
||||
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
|
||||
TextChunk tc = TextChunk.parse(orig);
|
||||
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
|
||||
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
|
||||
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(tex.entries.size());
|
||||
for (TextChunk.TextureEntry e : tex.entries) {
|
||||
b.putShort((short) e.flags())
|
||||
.put((byte) e.page())
|
||||
.put((byte) e.page2())
|
||||
.put((byte) e.u0())
|
||||
.put((byte) e.u1())
|
||||
.put((byte) e.v0())
|
||||
.put((byte) e.v1());
|
||||
}
|
||||
b.putInt(tex.anims.size());
|
||||
for (TextMerge.Anim a : tex.anims) {
|
||||
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
|
||||
}
|
||||
b.put(rest);
|
||||
tc.pages.clear();
|
||||
tc.pages.addAll(tex.pages);
|
||||
tc.tail = Arrays.copyOf(b.array(), b.position());
|
||||
return tc.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.eliasmueller.llamamod.mods.testworld;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Packs the gallery into rows along +x: one block of rows per world (front end last), figures
|
||||
* before objects, each item as wide as its model plus a gap. Rows are as deep as their biggest
|
||||
* model; an aisle runs in front (towards −z) of every row.
|
||||
*/
|
||||
final class Layout {
|
||||
/** Model units → game units (skinned models are posed by their bone matrices, rigid ones as stored). */
|
||||
static final double SCALE = 1.0;
|
||||
|
||||
static final double GAP = 0.15;
|
||||
static final double MIN_W = 0.3;
|
||||
static final double MIN_D = 0.35;
|
||||
static final double AISLE = 0.75;
|
||||
|
||||
record Slot(Gallery.Entry entry, double x, double z, double w, double d) {}
|
||||
|
||||
record Row(int world, double z, double depth, double x0, double x1, List<Slot> slots) {
|
||||
/** Centre of the aisle in front of the row. */
|
||||
double aisleZ() {
|
||||
return z - depth / 2 - AISLE / 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static final List<String> ORDER = List.of(
|
||||
"character",
|
||||
"player",
|
||||
"enemy",
|
||||
"creature",
|
||||
"other",
|
||||
"collectible",
|
||||
"checkpoint",
|
||||
"exit",
|
||||
"mechanism",
|
||||
"force",
|
||||
"effect",
|
||||
"trigger",
|
||||
"camera");
|
||||
|
||||
private Layout() {}
|
||||
|
||||
static int rank(Gallery.Entry e) {
|
||||
int i = ORDER.indexOf(e.category());
|
||||
return (e.figure() ? 0 : 100) + (i < 0 ? ORDER.size() : i);
|
||||
}
|
||||
|
||||
static double width(Gallery.Entry e) {
|
||||
return Math.max(MIN_W, SCALE * e.w()) + GAP;
|
||||
}
|
||||
|
||||
static double depth(Gallery.Entry e) {
|
||||
return Math.max(MIN_D, SCALE * e.d());
|
||||
}
|
||||
|
||||
/** Rows for the entries between x0 and x1, the first aisle starting at z0. */
|
||||
static List<Row> pack(List<Gallery.Entry> entries, double x0, double x1, double z0) {
|
||||
List<Integer> worlds = new ArrayList<>();
|
||||
for (Gallery.Entry e : entries) {
|
||||
int w = e.world() == 0 ? 99 : e.world();
|
||||
if (!worlds.contains(w)) {
|
||||
worlds.add(w);
|
||||
}
|
||||
}
|
||||
worlds.sort(Integer::compare);
|
||||
List<Row> rows = new ArrayList<>();
|
||||
double front = z0;
|
||||
for (int w : worlds) {
|
||||
List<Gallery.Entry> group = new ArrayList<>();
|
||||
for (Gallery.Entry e : entries) {
|
||||
if ((e.world() == 0 ? 99 : e.world()) == w) {
|
||||
group.add(e);
|
||||
}
|
||||
}
|
||||
group.sort(Comparator.comparingInt(Layout::rank));
|
||||
List<List<Gallery.Entry>> lines = new ArrayList<>();
|
||||
List<Gallery.Entry> line = new ArrayList<>();
|
||||
double x = x0;
|
||||
for (Gallery.Entry e : group) {
|
||||
double fw = width(e);
|
||||
if (!line.isEmpty() && x + fw > x1) {
|
||||
lines.add(line);
|
||||
line = new ArrayList<>();
|
||||
x = x0;
|
||||
}
|
||||
line.add(e);
|
||||
x += fw;
|
||||
}
|
||||
if (!line.isEmpty()) {
|
||||
lines.add(line);
|
||||
}
|
||||
for (List<Gallery.Entry> l : lines) {
|
||||
double dep = 0;
|
||||
for (Gallery.Entry e : l) {
|
||||
dep = Math.max(dep, depth(e));
|
||||
}
|
||||
double z = front + AISLE + dep / 2;
|
||||
List<Slot> slots = new ArrayList<>();
|
||||
double cx = x0;
|
||||
for (Gallery.Entry e : l) {
|
||||
double fw = width(e);
|
||||
slots.add(new Slot(e, cx + fw / 2, z, fw - GAP, depth(e)));
|
||||
cx += fw;
|
||||
}
|
||||
rows.add(new Row(w == 99 ? 0 : w, z, dep, x0, cx, List.copyOf(slots)));
|
||||
front = z + dep / 2;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -9,73 +9,51 @@ import com.eliasmueller.llamamod.levelbuild.PyRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Test world (port of levels/testwelt.py), replaces TestLevel chapter 1.
|
||||
* Test world (based on levels/testwelt.py), replaces TestLevel chapter 1.
|
||||
*
|
||||
* <p>Options: {@code gallery=all|none|<comma-separated types>}, {@code start=plaza|row1..4|path1..4|
|
||||
* enclosure|podium|parkour|pk1..pk5|goal|goal2} (llama start, for tests), {@code door=1,9,1} (exit
|
||||
* parameters: tribe, world, chapter of the next level).
|
||||
* <p>Options: {@code gallery=all|none|<comma-separated type names>}, {@code start=plaza|rowN|pathN|
|
||||
* parkour|pk1..pk5|goal|goal2} (llama start, for tests; rowN = aisle in front of gallery row N,
|
||||
* pathN = its west end), {@code door=1,9,1} (exit parameters: tribe, world, chapter of the next level).
|
||||
*
|
||||
* <p>Scale (measured in game): llama ~0.6 tall, runs ~1.1 units/s, jumps 0.35 high and ~1.3 far
|
||||
* with a run-up. Steps ≤ 0.3 (without gap = stopper), gaps 0.3, otherwise stairs or ramps.
|
||||
*
|
||||
* <p>Layout: L-shaped island surrounded by sea (death plane, respawn at the checkpoint). Start
|
||||
* plaza (x 15..21, z 12..19); object gallery south of it (4 rows: collectibles, crates/pots,
|
||||
* characters, mechanics); animal enclosure east of the gallery with a viewing podium; parkour along
|
||||
* z = 15.5 from the plaza's east edge to the goal platform (y 4) with the exit under a gate.
|
||||
* <p>Layout: island surrounded by sea (death plane, respawn at the checkpoint). Start plaza (x
|
||||
* 15..21, z 12..19); south of it the gallery: every object model of the game as a motionless
|
||||
* display strat ({@link Gallery}), one block of rows per world (post colour and the number of
|
||||
* blocks on the post = world, none = front end), figures before objects; parkour along z = 15.5 from
|
||||
* the plaza's east edge to the goal platform (y 4) with the exit under a gate.
|
||||
*/
|
||||
public final class TestWorld implements LevelScript {
|
||||
/** Gallery rows: category, post colour, types ("Name" or "Name#variant"). */
|
||||
private static final List<Row> GALLERY = List.of(
|
||||
new Row(
|
||||
"collectibles",
|
||||
"yellow",
|
||||
List.of(
|
||||
"Coin",
|
||||
"ChargeBooster",
|
||||
"RubyIdol",
|
||||
"HealthFull",
|
||||
"HealthGenerator",
|
||||
"ChacaChargePotion",
|
||||
"Secret")),
|
||||
new Row(
|
||||
"crates/pots",
|
||||
"grey",
|
||||
List.of(
|
||||
"SmashPot#0",
|
||||
"SmashPot#1",
|
||||
"Jar",
|
||||
"ObjectDontDropInBox",
|
||||
"WaterReboundPoints",
|
||||
"CheatTeleport")),
|
||||
new Row(
|
||||
"characters",
|
||||
"blue",
|
||||
List.of("TipoVillage", "ChacaVillage", "PachaGivingInfo", "KidOnLlama", "Wompy")),
|
||||
new Row(
|
||||
"mechanics",
|
||||
"red",
|
||||
List.of(
|
||||
"PressurePadDoor",
|
||||
"RubyIdolDoor",
|
||||
"SwitchPlatformPadVillage",
|
||||
"AppearingPlatformsVillage",
|
||||
"RisingColumns",
|
||||
"SinkingStatue",
|
||||
"FallingTree",
|
||||
"CheckPointPost")));
|
||||
private static final double GX0 = 16.6;
|
||||
private static final double GX1 = 43.0;
|
||||
/** North edge of the gallery (first aisle). */
|
||||
private static final double GZ0 = 20.5;
|
||||
|
||||
private static final List<String> ENCLOSURE =
|
||||
List.of("Bunny", "Bunny", "Ladybird", "FlockOfBirds", "VillagerHerder");
|
||||
private static final double PEDESTAL = 0.08;
|
||||
/** Yaw of the gallery models: facing the aisle (−z). */
|
||||
private static final double FACING = 180;
|
||||
/** Post colour per world (index 0 = front end). */
|
||||
private static final String[] WORLD_COLOURS = {
|
||||
LevelBuilder.rgb(235, 235, 235),
|
||||
"yellow",
|
||||
LevelBuilder.rgb(60, 150, 60),
|
||||
LevelBuilder.rgb(70, 170, 200),
|
||||
LevelBuilder.rgb(40, 90, 40),
|
||||
LevelBuilder.rgb(150, 150, 170),
|
||||
"red",
|
||||
LevelBuilder.rgb(130, 70, 160),
|
||||
"blue",
|
||||
"grey"
|
||||
};
|
||||
|
||||
private record Row(String category, String colour, List<String> types) {}
|
||||
|
||||
private static final double GX0 = 17.4;
|
||||
private static final double DX = 1.9;
|
||||
private static final double[] ROW_Z = {22.5, 25.2, 27.9, 30.6};
|
||||
/** Collision material "exit floor": LevelDoor only fires while the llama stands on it in the door's cell. */
|
||||
private static final int DOOR_MAT = 16;
|
||||
/** Offset of the parkour against its first version (moved only, sizes unchanged). */
|
||||
@@ -83,6 +61,9 @@ public final class TestWorld implements LevelScript {
|
||||
|
||||
private final List<String> placed = new ArrayList<>();
|
||||
private final List<String> skipped = new ArrayList<>();
|
||||
private List<Gallery.Entry> gallery = List.of();
|
||||
private List<Gallery.Skip> notShown = List.of();
|
||||
private final List<Layout.Row> rows = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public String levelName() {
|
||||
@@ -94,22 +75,73 @@ public final class TestWorld implements LevelScript {
|
||||
return 7;
|
||||
}
|
||||
|
||||
/** Gallery types placed in the last build (original type names). */
|
||||
public List<String> placed() {
|
||||
return placed;
|
||||
}
|
||||
|
||||
/** Selected types that are not in the gallery (unknown names). */
|
||||
public List<String> skipped() {
|
||||
return skipped;
|
||||
}
|
||||
|
||||
/** Gallery entries of the last build in placement order. */
|
||||
public List<Gallery.Entry> gallery() {
|
||||
return gallery;
|
||||
}
|
||||
|
||||
/** Types of the game that have no gallery entry, with the reason. */
|
||||
public List<Gallery.Skip> notShown() {
|
||||
return notShown;
|
||||
}
|
||||
|
||||
public String report() {
|
||||
String s = "Gallery: " + placed.size() + " objects: " + String.join(", ", placed);
|
||||
if (!skipped.isEmpty()) {
|
||||
s += "\nnot in the base level: " + String.join(", ", skipped);
|
||||
s += "\nunknown types: " + String.join(", ", skipped);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Imports the gallery's display types into the base level, then builds as usual. */
|
||||
@Override
|
||||
public byte[] buildWad(byte[] baseWad, Function<String, byte[]> wads, Map<String, String> opts) {
|
||||
String sel = opts == null ? "all" : opts.getOrDefault("gallery", "all");
|
||||
byte[] base = baseWad;
|
||||
gallery = List.of();
|
||||
notShown = List.of();
|
||||
skipped.clear();
|
||||
if (!sel.equals("none")) {
|
||||
Gallery g = Gallery.build(wads);
|
||||
Gallery.Scan sc = g.scan();
|
||||
List<Gallery.Entry> chosen = new ArrayList<>();
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for (Gallery.Entry e : sc.entries()) {
|
||||
if (selected(sel, e.type())) {
|
||||
chosen.add(e);
|
||||
names.add(e.type());
|
||||
}
|
||||
}
|
||||
if (!sel.equals("all")) {
|
||||
for (String n : sel.split(",")) {
|
||||
if (!names.contains(n.trim())) {
|
||||
skipped.add(n.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen.isEmpty()) {
|
||||
base = g.mergeInto(baseWad, names).wad();
|
||||
}
|
||||
gallery = List.copyOf(chosen);
|
||||
notShown = sc.skipped();
|
||||
}
|
||||
List<String> keep = List.copyOf(skipped);
|
||||
byte[] out = LevelScript.super.buildWad(base, wads, opts);
|
||||
skipped.clear();
|
||||
skipped.addAll(keep);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double x(double v) {
|
||||
return v + PX;
|
||||
}
|
||||
@@ -125,8 +157,11 @@ public final class TestWorld implements LevelScript {
|
||||
@Override
|
||||
public void build(LevelBuilder l) {
|
||||
placed.clear();
|
||||
skipped.clear();
|
||||
String sel = l.opt("gallery", "all");
|
||||
rows.clear();
|
||||
rows.addAll(Layout.pack(gallery, GX0, GX1, GZ0));
|
||||
double gz1 = rows.isEmpty()
|
||||
? 32
|
||||
: Math.max(32, rows.getLast().z() + rows.getLast().depth() / 2 + 0.8);
|
||||
PyRandom rnd = new PyRandom(3);
|
||||
l.sun.azimuth = 220;
|
||||
l.sun.elevation = 48;
|
||||
@@ -135,13 +170,13 @@ public final class TestWorld implements LevelScript {
|
||||
l.sun.ground = new double[] {0.27, 0.25, 0.21};
|
||||
|
||||
// ---------------------------------------------------------------- island + sea
|
||||
// main part (plaza, gallery, enclosure) plus a narrow strip under the end of the parkour
|
||||
// main part (plaza, gallery) plus a narrow strip under the end of the parkour
|
||||
double x0 = 11;
|
||||
double z0 = 9.5;
|
||||
double x1 = 43.5;
|
||||
double z1 = 33;
|
||||
double z1 = gz1 + 1;
|
||||
double xb = 49;
|
||||
l.sea(0, 0, 60, 42, -1.5, new double[] {x0 - 4, z0 - 4, 54, z1 + 4});
|
||||
l.sea(0, 0, 60, z1 + 9, -1.5, new double[] {x0 - 4, z0 - 4, 54, z1 + 4});
|
||||
l.box(
|
||||
x0,
|
||||
z0,
|
||||
@@ -164,8 +199,7 @@ public final class TestWorld implements LevelScript {
|
||||
l.floor(x0, 20.5, 14.5, z1, 0, "grass");
|
||||
l.floor(21, 12, xb, 20.5, 0, "grass2");
|
||||
l.floor(15, 19, 21, 20.5, 0, "grass");
|
||||
l.floor(32.5, 20.5, x1, 32, 0, "grass");
|
||||
l.floor(14.5, 32, x1, z1, 0, "grass");
|
||||
l.floor(14.5, gz1, x1, z1, 0, "grass");
|
||||
|
||||
// ---------------------------------------------------------------- start plaza (6 x 7)
|
||||
l.box(15, 12, 21, 19, -0.25, 0.0, o().top("stone").side("stone_edge"));
|
||||
@@ -209,86 +243,52 @@ public final class TestWorld implements LevelScript {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- gallery (south of the plaza)
|
||||
l.box(14.5, 20.5, 32.5, 32, -0.2, 0.0, o().top(NONE).side("stone").solid(false));
|
||||
l.floor(14.5, 20.5, 32.5, 32, 0, "grass");
|
||||
l.floor(15.6, 20.5, 16.3, 32, 0.02, "sand", o().coll(false));
|
||||
for (int r = 0; r < GALLERY.size(); r++) {
|
||||
Row row = GALLERY.get(r);
|
||||
double zr = ROW_Z[r];
|
||||
double xe = GX0 + (row.types.size() - 1) * DX + 0.7;
|
||||
l.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, "sand", o().coll(false));
|
||||
// row post in the category colour at the start of the path
|
||||
l.pillar(15.05, zr - 0.3, 0, 1.1, 0.25, o().side(row.colour).top(row.colour));
|
||||
l.box(
|
||||
14.85,
|
||||
zr - 0.5,
|
||||
15.25,
|
||||
zr - 0.1,
|
||||
1.1,
|
||||
1.25,
|
||||
o().top(row.colour).side("wood_dark").rot(0));
|
||||
double x = GX0;
|
||||
for (String t : row.types) {
|
||||
int hash = t.indexOf('#');
|
||||
String name = hash < 0 ? t : t.substring(0, hash);
|
||||
int var = hash < 0 ? 0 : Integer.parseInt(t.substring(hash + 1));
|
||||
l.box(
|
||||
x - 0.45,
|
||||
zr - 0.45,
|
||||
x + 0.45,
|
||||
zr + 0.45,
|
||||
0,
|
||||
0.08,
|
||||
o().top("stone_green").side("stone_edge").rot(0).sideColl(false));
|
||||
if (selected(sel, name)) {
|
||||
if (l.hasType(name) && var < l.variants(name)) {
|
||||
l.strat(name, x, zr, var);
|
||||
placed.add(var == 0 ? name : name + "#" + var);
|
||||
} else {
|
||||
skipped.add(name);
|
||||
}
|
||||
l.box(14.5, 20.5, x1, gz1, -0.2, 0.0, o().top(NONE).side("stone").solid(false));
|
||||
l.floor(14.5, 20.5, x1, gz1, 0, "grass");
|
||||
l.floor(15.6, 20.5, 16.3, gz1, 0.02, "sand", o().coll(false));
|
||||
int[] count = new int[10];
|
||||
for (Layout.Row row : rows) {
|
||||
String colour = WORLD_COLOURS[row.world()];
|
||||
double az = row.aisleZ();
|
||||
l.floor(16.3, az - 0.3, row.x1(), az + 0.3, 0.02, "sand", o().coll(false));
|
||||
// row post in the world colour, one block per world number on top (first row of a world)
|
||||
l.pillar(15.05, row.z(), 0, 1.1, 0.25, o().side(colour).top(colour));
|
||||
if (count[row.world()]++ == 0) {
|
||||
for (int k = 0; k < row.world(); k++) {
|
||||
double y = 1.1 + k * 0.16;
|
||||
l.box(
|
||||
14.97,
|
||||
row.z() - 0.08,
|
||||
15.13,
|
||||
row.z() + 0.08,
|
||||
y,
|
||||
y + 0.12,
|
||||
o().top(colour).side(colour).rot(0));
|
||||
}
|
||||
x += DX;
|
||||
}
|
||||
for (Layout.Slot sl : row.slots()) {
|
||||
l.box(
|
||||
sl.x() - sl.w() / 2,
|
||||
sl.z() - sl.d() / 2,
|
||||
sl.x() + sl.w() / 2,
|
||||
sl.z() + sl.d() / 2,
|
||||
0,
|
||||
PEDESTAL,
|
||||
o().top("stone_green").side("stone_edge").rot(0).sideColl(false));
|
||||
l.strat(
|
||||
sl.entry().displayName(),
|
||||
sl.x(),
|
||||
sl.z(),
|
||||
PEDESTAL,
|
||||
FACING,
|
||||
null,
|
||||
LevelBuilder.Waypoints.NONE,
|
||||
0,
|
||||
false);
|
||||
placed.add(sl.entry().type());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- enclosure (animals), east of the gallery
|
||||
double gx0 = 33;
|
||||
double gz0 = 20.8;
|
||||
double gx1 = 42;
|
||||
double gz1 = 30;
|
||||
l.floor(gx0, gz0, gx1, gz1, 0.015, "grass_striped", o().coll(false));
|
||||
l.fence(
|
||||
List.of(
|
||||
new double[] {gx0, gz0},
|
||||
new double[] {gx1, gz0},
|
||||
new double[] {gx1, gz1},
|
||||
new double[] {gx0, gz1},
|
||||
new double[] {gx0, gz0 + 3.2}),
|
||||
0,
|
||||
0.45,
|
||||
0.07,
|
||||
1.0);
|
||||
tree(l, gx1 - 1.3, gz1 - 1.3, 1.8, 1.1);
|
||||
tree(l, gx1 - 1.2, gz0 + 1.2, 1.4, 1.1);
|
||||
for (int i = 0; i < ENCLOSURE.size(); i++) {
|
||||
String name = ENCLOSURE.get(i);
|
||||
if (selected(sel, name)) {
|
||||
l.strat(name, gx0 + 1.8 + (i % 3) * 2.7, gz0 + 2.3 + (i / 3) * 4.5);
|
||||
placed.add(name);
|
||||
}
|
||||
}
|
||||
// viewing podium (stairs up, between rows 2 and 3; enclosure entrance north of it)
|
||||
l.stairs(31.2, 25.3, 32.4, 26.9, 0, 0.6, 3, "+x", o().top("wood_grain").side("wood_dark"));
|
||||
l.box(
|
||||
32.4,
|
||||
24.7,
|
||||
33.0,
|
||||
27.5,
|
||||
0,
|
||||
0.6,
|
||||
o().top("wood_grain").side("wood_dark").rot(0));
|
||||
|
||||
// ---------------------------------------------------------------- parkour
|
||||
// All on the line z = 15.5 heading east, so it can be run without turning the camera. Two
|
||||
// "stopper" steps (+0.3, no gap) halt the llama at a fixed spot: jump from standing, then
|
||||
@@ -445,11 +445,11 @@ public final class TestWorld implements LevelScript {
|
||||
{12.3, 22.5},
|
||||
{12.6, 27},
|
||||
{12.2, 31.5},
|
||||
{17.5, 32.6},
|
||||
{25, 32.7},
|
||||
{36, 32.5},
|
||||
{42.6, 32.3},
|
||||
{42.8, 23},
|
||||
{17.5, z1 - 0.4},
|
||||
{25, z1 - 0.3},
|
||||
{36, z1 - 0.5},
|
||||
{42.6, z1 - 0.7},
|
||||
{12.4, z1 - 1.2},
|
||||
{29, 10.5},
|
||||
{35, 10.4},
|
||||
{41, 10.6},
|
||||
@@ -460,8 +460,9 @@ public final class TestWorld implements LevelScript {
|
||||
double c = rnd.uniform(0.9, 1.3);
|
||||
tree(l, p[0], p[1], h, c);
|
||||
}
|
||||
for (double[] p :
|
||||
new double[][] {{13.2, 25}, {21.5, 32.5}, {30.5, 32.6}, {39, 32.6}, {26.5, 19.6}, {39.5, 19.4}}) {
|
||||
for (double[] p : new double[][] {
|
||||
{13.2, 25}, {21.5, z1 - 0.5}, {30.5, z1 - 0.4}, {39, z1 - 0.4}, {26.5, 19.6}, {39.5, 19.4}
|
||||
}) {
|
||||
double r = rnd.uniform(0.3, 0.5);
|
||||
l.box(
|
||||
p[0] - r,
|
||||
@@ -473,8 +474,12 @@ public final class TestWorld implements LevelScript {
|
||||
o().top("stone_moss").side("stone_blue"));
|
||||
}
|
||||
Object[][] beds = {
|
||||
{11.4, 19.4, 2.0, 1.8, "flowers"}, {14.8, 32.1, 2.0, 0.8, "flowers2"}, {33.5, 32.1, 2.5, 0.8, "flowers3"},
|
||||
{31.0, 11.0, 2.0, 1.0, "flowers2"}, {40.5, 18.6, 2.0, 1.6, "flowers"}, {25.0, 10.4, 1.5, 1.4, "flowers3"}
|
||||
{11.4, 19.4, 2.0, 1.8, "flowers"},
|
||||
{14.8, z1 - 0.9, 2.0, 0.8, "flowers2"},
|
||||
{33.5, z1 - 0.9, 2.5, 0.8, "flowers3"},
|
||||
{31.0, 11.0, 2.0, 1.0, "flowers2"},
|
||||
{40.5, 18.6, 2.0, 1.6, "flowers"},
|
||||
{25.0, 10.4, 1.5, 1.4, "flowers3"}
|
||||
};
|
||||
for (Object[] b : beds) {
|
||||
double bx = (Double) b[0];
|
||||
@@ -493,11 +498,10 @@ public final class TestWorld implements LevelScript {
|
||||
starts.put("pk5", new double[] {x(46.0), 15.5, 90});
|
||||
starts.put("goal", new double[] {x(49.0), 15.5, 90});
|
||||
starts.put("goal2", new double[] {x(51.5), 17.8, 180});
|
||||
starts.put("enclosure", new double[] {30.4, 26.1, 90});
|
||||
starts.put("podium", new double[] {32.7, 26.1, 270});
|
||||
for (int i = 0; i < ROW_Z.length; i++) {
|
||||
starts.put("row" + (i + 1), new double[] {15.95, ROW_Z[i], 90});
|
||||
starts.put("path" + (i + 1), new double[] {15.95, ROW_Z[i] - 1.0, 90});
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Layout.Row r = rows.get(i);
|
||||
starts.put("row" + (i + 1), new double[] {(r.x0() + r.x1()) / 2, r.aisleZ(), 90});
|
||||
starts.put("path" + (i + 1), new double[] {15.95, r.aisleZ(), 90});
|
||||
}
|
||||
String start = l.opt("start", "plaza");
|
||||
double[] st = starts.get(start);
|
||||
|
||||
@@ -34,8 +34,8 @@ public final class TestWorldMod implements Mod {
|
||||
@Override
|
||||
public String description() {
|
||||
return "Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every"
|
||||
+ " object in the game, an animal enclosure and a parkour course with checkpoints. To reach it,"
|
||||
+ " click \"Unlock TestLevel\" on the Save games tab.";
|
||||
+ " character and object model of all worlds (standing still, grouped by world) and a parkour"
|
||||
+ " course with checkpoints. To reach it, click \"Unlock TestLevel\" on the Save games tab.";
|
||||
}
|
||||
|
||||
/** Before mods that rework every level (e.g. the debug wireframe), so they see the new level. */
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import com.eliasmueller.llamamod.levelbuild.BrushRecorder;
|
||||
import com.eliasmueller.llamamod.mods.testworld.TestWorld;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -17,15 +16,18 @@ import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The test world (levels/testwelt.py) as a brush project: examples/testwelt.llamalevel. Its build
|
||||
* must equal the Test World mod's WAD except for the death plane model (generated instead of the
|
||||
* village's model 0). -Dllamamod.writeExamples=true also writes the file into examples/.
|
||||
* The original test world (levels/testwelt.py) as a brush project: examples/testwelt.llamalevel. Its
|
||||
* build must equal the level builder's WAD of the same script except for the death plane model
|
||||
* (generated instead of the village's model 0). -Dllamamod.writeExamples=true also writes the file
|
||||
* into examples/.
|
||||
*/
|
||||
class TestWorldExampleTest {
|
||||
private static byte[] reference;
|
||||
|
||||
static Project testWorld(Bases cd) throws IOException {
|
||||
Project p = Project.create(cd, "new", Templates.DEFAULT_BASE);
|
||||
Scene s = p.scene();
|
||||
BrushRecorder.testWorld(s, cd.wad(Templates.DEFAULT_BASE), cd::fileOrNull, Map.of());
|
||||
reference = BrushRecorder.testWorld(s, cd.wad(Templates.DEFAULT_BASE), cd::fileOrNull, Map.of());
|
||||
s.settings().prop("targetChapter").setRaw(s.settings(), 2);
|
||||
s.settings().prop("author").setRaw(s.settings(), "LlamaMod");
|
||||
s.settings()
|
||||
@@ -43,7 +45,7 @@ class TestWorldExampleTest {
|
||||
Project p = testWorld(cd);
|
||||
WadBuilder.Result r = WadBuilder.buildWithReport(p.scene());
|
||||
assertTrue(r.warnings().isEmpty(), r.warnings().toString());
|
||||
byte[] ref = new TestWorld().buildWad(cd.wad(Templates.DEFAULT_BASE), cd::fileOrNull, Map.of());
|
||||
byte[] ref = reference;
|
||||
Level a = Level.parse(r.wad());
|
||||
Level b = Level.parse(ref);
|
||||
assertEquals(b.models().size(), a.models().size(), "models");
|
||||
|
||||
@@ -29,13 +29,17 @@ public final class BrushRecorder {
|
||||
this.sun = lb.sun;
|
||||
}
|
||||
|
||||
/** Fills an empty new-level scene with the test world (levels/testwelt.py). */
|
||||
public static void testWorld(Scene s, byte[] baseWad, Function<String, byte[]> cd, Map<String, String> opts) {
|
||||
/**
|
||||
* Fills an empty new-level scene with the original test world (levels/testwelt.py) and returns
|
||||
* the WAD the level builder makes from the same calls (reference for the brush build).
|
||||
*/
|
||||
public static byte[] testWorld(Scene s, byte[] baseWad, Function<String, byte[]> cd, Map<String, String> opts) {
|
||||
TestWorldBrushes tw = new TestWorldBrushes();
|
||||
LevelBuilder lb = new LevelBuilder(baseWad, cd, tw.levelName(), tw.seed(), opts);
|
||||
BrushRecorder r = new BrushRecorder(lb, s);
|
||||
tw.build(r);
|
||||
r.finish(tw.levelName(), tw.seed());
|
||||
return lb.build();
|
||||
}
|
||||
|
||||
public String opt(String key, String def) {
|
||||
|
||||
@@ -10,8 +10,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Copy of mods.testworld.TestWorld (port of levels/testwelt.py) driving a {@link BrushRecorder}: generator of
|
||||
* examples/testwelt.llamalevel.
|
||||
* The original test world (port of levels/testwelt.py, before the Test World mod got its all-worlds
|
||||
* gallery) driving a {@link BrushRecorder}: generator of examples/testwelt.llamalevel.
|
||||
*
|
||||
* <p>Options: {@code gallery=all|none|<comma-separated types>}, {@code start=plaza|row1..4|path1..4|
|
||||
* enclosure|podium|parkour|pk1..pk5|goal|goal2} (llama start, for tests), {@code door=1,9,1} (exit
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.eliasmueller.llamamod.mods.testworld;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.render.StratModels;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
|
||||
import com.eliasmueller.llamamod.format.strat.OpTable;
|
||||
import com.eliasmueller.llamamod.format.strat.StratListing;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.mods.GameData;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The test world's gallery: every entry shows a model through a static display strat. */
|
||||
class GalleryTest {
|
||||
/** Everything a display strat may execute: name, model, animation restart, yield. */
|
||||
private static final Set<String> STATIC_OPS =
|
||||
Set.of("debugName", "address", "setModel", "animPlay", "pushProp", "not", "jz", "jmpYield", "end");
|
||||
|
||||
private static Function<String, byte[]> wads(Path cd) {
|
||||
return rel -> {
|
||||
try {
|
||||
Path p = cd.resolve(rel);
|
||||
return Files.exists(p) ? Files.readAllBytes(p) : null;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEntryHasAModelAndAStaticScript() throws IOException {
|
||||
Path cd = GameData.cd();
|
||||
TestWorld tw = new TestWorld();
|
||||
byte[] wad = tw.buildWad(Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD)), wads(cd), Map.of());
|
||||
List<Gallery.Entry> entries = tw.gallery();
|
||||
assertTrue(entries.size() > 200, "gallery entries: " + entries.size());
|
||||
assertEquals(entries.size(), tw.placed().size());
|
||||
|
||||
Level lv = Level.parse(wad);
|
||||
byte[] st = lv.wad().require("STPC");
|
||||
StpcImage img = new StpcImage(st, lv.wfpc());
|
||||
Map<String, Integer> codes = new HashMap<>();
|
||||
Map<String, Integer> placed = new HashMap<>();
|
||||
TreeSet<Integer> roots = new TreeSet<>();
|
||||
for (MapChunk.Strat s : lv.map().strats) {
|
||||
roots.add(s.code());
|
||||
String n = Strats.nameOr(st, s.code(), img.codeStart, "?");
|
||||
codes.put(n, s.code());
|
||||
placed.merge(n, 1, Integer::sum);
|
||||
if (n.startsWith(Gallery.PREFIX)) {
|
||||
assertEquals(0, s.typeFlags(), n + ": no collision/physics block");
|
||||
assertEquals(0, s.nParams(), n);
|
||||
assertEquals(-1, s.waypoint(), n);
|
||||
}
|
||||
}
|
||||
StratCode code = StratCode.analyse(img, roots);
|
||||
assertTrue(code.errors.isEmpty(), code.errors.toString());
|
||||
StratModels sm = new StratModels(st, lv.wfpc());
|
||||
OpTable ops = OpTable.get();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Gallery.Entry e : entries) {
|
||||
String n = e.displayName();
|
||||
assertTrue(names.add(n), "duplicate " + n);
|
||||
Integer c = codes.get(n);
|
||||
assertTrue(c != null, n + " not placed");
|
||||
assertEquals(1, placed.get(n), n + " placed once");
|
||||
int mi = sm.modelOf(c);
|
||||
assertTrue(mi >= 0, n + ": no model");
|
||||
assertTrue(!sm.models.get(mi).faces.isEmpty(), n + ": empty model");
|
||||
assertEquals(e.figure(), StratModels.skinned(sm.models.get(mi)), n + ": figure = skinned model");
|
||||
if (e.figure()) {
|
||||
assertTrue(sm.posed(c, mi) != null, n + ": skinned model without animation");
|
||||
}
|
||||
int unit = img.unitOf(c);
|
||||
StratListing li = StratListing.of(img, code, unit, roots, StratListing.Names.NONE);
|
||||
for (StratListing.Line l : li.lines) {
|
||||
if (l.kind() == StratListing.Kind.INSTR) {
|
||||
String m = ops.op(l.op()).name();
|
||||
assertTrue(STATIC_OPS.contains(m), n + ": unexpected op " + m + " (" + l.text() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rowsDoNotOverlap() throws IOException {
|
||||
Path cd = GameData.cd();
|
||||
Gallery g = Gallery.build(wads(cd));
|
||||
List<Layout.Row> rows = Layout.pack(g.scan().entries(), 16.6, 43.0, 20.5);
|
||||
double front = 20.5;
|
||||
for (Layout.Row r : rows) {
|
||||
assertTrue(r.z() - r.depth() / 2 >= front + Layout.AISLE - 1e-9, "aisle in front of row " + r);
|
||||
double x = r.x0();
|
||||
for (Layout.Slot s : r.slots()) {
|
||||
double half = Math.max(s.w(), Layout.SCALE * s.entry().w()) / 2;
|
||||
assertTrue(s.x() - half >= x - 1e-9, s.entry().type() + " overlaps its neighbour");
|
||||
assertTrue(
|
||||
Layout.SCALE * s.entry().d() <= r.depth() + 1e-9,
|
||||
s.entry().type() + " deeper than the row");
|
||||
x = s.x() + half;
|
||||
}
|
||||
assertTrue(x <= 43.0 + 1e-9 || r.slots().size() == 1, "row too long");
|
||||
front = r.z() + r.depth() / 2;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void galleryOption() throws IOException {
|
||||
Path cd = GameData.cd();
|
||||
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
|
||||
TestWorld none = new TestWorld();
|
||||
byte[] w = none.buildWad(base, wads(cd), Map.of("gallery", "none"));
|
||||
assertTrue(none.placed().isEmpty());
|
||||
assertArrayEquals(
|
||||
Level.parse(base).wad().require("STPC"), Level.parse(w).wad().require("STPC"), "nothing imported");
|
||||
|
||||
TestWorld some = new TestWorld();
|
||||
some.buildWad(base, wads(cd), Map.of("gallery", "TipoVillage,Spider,NoSuchType"));
|
||||
assertEquals(List.of("TipoVillage", "Spider"), some.placed());
|
||||
assertEquals(List.of("NoSuchType"), some.skipped());
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,17 @@ import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Generated levels vs. tools/levelbuild.py (needs -Dllamamod.cd). With -Dllamamod.lbref=DIR the
|
||||
* files there are compared byte by byte (first difference reported), otherwise the SHA-256 of the
|
||||
* Python output (python3 tools/levelbuild.py levels/testwelt.py OUT [--set start=parkour];
|
||||
* levels/sprunglabor.py OUT --set spur=a; tools/png2cor.py ladebild.png OUT).
|
||||
* Generated levels (needs -Dllamamod.cd). Jump lab and loading picture vs. the Python tools: with
|
||||
* -Dllamamod.lbref=DIR the files there are compared byte by byte (first difference reported),
|
||||
* otherwise the SHA-256 of the Python output (levels/sprunglabor.py OUT --set spur=a;
|
||||
* tools/png2cor.py ladebild.png OUT). The test world has no Python counterpart any more (its
|
||||
* all-worlds gallery is imported with format.stpcmerge): its SHA-256 pins the current output, so a
|
||||
* change of the level (or of the cross-world merge) has to be confirmed here deliberately.
|
||||
*/
|
||||
class TestWorldGoldenTest {
|
||||
private static final String TEST_WORLD_SHA = "786a8f9e907a6bbd22a08798e7eb1c07c2d8d96893fbbe6fdb37363c02b03643";
|
||||
private static final String PARKOUR_SHA = "858f69883c04763b19ee98d23bef68df5563a093e1f37af864458286d98bcbc8";
|
||||
private static final int GALLERY_SIZE = 253;
|
||||
|
||||
private static byte[] build(Path cd, LevelScript script, Map<String, String> opts) throws IOException {
|
||||
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
|
||||
@@ -68,14 +73,14 @@ class TestWorldGoldenTest {
|
||||
void testWorldDefault() throws IOException {
|
||||
TestWorld tw = new TestWorld();
|
||||
byte[] out = build(GameData.cd(), tw, Map.of());
|
||||
check(out, "testwelt.wad", "8cd6ff039e82a4fcbcf01e62c91c1931c685dd9907326349aedeb04c31d92c7b");
|
||||
assertEquals(31, tw.placed().size());
|
||||
assertEquals(TEST_WORLD_SHA, sha256(out), "test world SHA-256");
|
||||
assertEquals(GALLERY_SIZE, tw.placed().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWorldParkourStart() throws IOException {
|
||||
byte[] out = build(GameData.cd(), new TestWorld(), Map.of("start", "parkour"));
|
||||
check(out, "testwelt_parkour.wad", "1ad12e03c23f267bba127aa57231295e2e681f6afbe2f1f7c286c2ae3c67bdf1");
|
||||
assertEquals(PARKOUR_SHA, sha256(out), "test world (start=parkour) SHA-256");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -126,9 +131,7 @@ class TestWorldGoldenTest {
|
||||
public void progress(double fraction, String message) {}
|
||||
});
|
||||
assertEquals(List.of("T191.COR", "wads/T1L9M001.wad"), List.copyOf(written.keySet()));
|
||||
assertEquals(
|
||||
"8cd6ff039e82a4fcbcf01e62c91c1931c685dd9907326349aedeb04c31d92c7b",
|
||||
sha256(written.get("wads/T1L9M001.wad")));
|
||||
assertEquals(TEST_WORLD_SHA, sha256(written.get("wads/T1L9M001.wad")));
|
||||
assertEquals(
|
||||
"c55e3babf4855540f14daa60b979a2534e3732037854d9ad785f69f370d095d0", sha256(written.get("T191.COR")));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user