testlevel chapters 2-31 overwrite game memory (coin counter and flag rows end after world 8: progress, language, options): level projects, play-tests and the arena use chapters 1 and 32-49, old settings migrate; level tally counts the level's own coins and secrets
This commit is contained in:
1 parent
987b0104fa
commit
d6b852a1b6
35 files changed
+1015
-175
No files matched your search
+8
-2
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Command;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
@@ -38,6 +39,7 @@ import javax.swing.JScrollPane;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SpinnerListModel;
|
||||
|
||||
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
|
||||
final class LevelSettingsDialog {
|
||||
@@ -80,9 +82,13 @@ final class LevelSettingsDialog {
|
||||
"Base level",
|
||||
new JLabel(st.base() + " (object code, textures, sounds)"),
|
||||
"Fixed when the project is created (the project stores only the differences to it)");
|
||||
JSpinner chapter = Ui.number(Math.max(1, st.targetChapter()), 1, 49, 1, "0", v -> {});
|
||||
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
|
||||
chapter.setValue(TestLevelChapters.migrate(st.targetChapter()));
|
||||
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
|
||||
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
|
||||
f.row(
|
||||
"TestLevel chapter",
|
||||
chapter,
|
||||
"New levels replace this chapter of world 9 (TestLevel): 32–49, or 1 (the Test World's)");
|
||||
f.full(used);
|
||||
chapter.setEnabled(st.isNewLevel());
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.platform.Config;
|
||||
import com.eliasmueller.llamamod.platform.GameLauncher;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -50,7 +51,7 @@ public final class PlaytestRunner {
|
||||
/** World/chapter of a project: TestLevel chapter, or the original's place; null if not on the map. */
|
||||
public static int[] target(Document d) {
|
||||
if (d.isNewLevel()) {
|
||||
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
|
||||
return new int[] {9, TestLevelChapters.migrate(d.scene().settings().targetChapter())};
|
||||
}
|
||||
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
|
||||
if (m.matches()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
|
||||
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
|
||||
import com.eliasmueller.llamamod.editor.io.Templates;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
@@ -28,7 +29,7 @@ import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import javax.swing.SpinnerListModel;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
/** New level: name, base level (object types, textures, sky, music), template, TestLevel chapter. */
|
||||
@@ -51,7 +52,8 @@ final class NewLevelDialog extends JDialog {
|
||||
base.setSelectedItem(l);
|
||||
}
|
||||
}
|
||||
JSpinner chapter = new JSpinner(new SpinnerNumberModel(nextFreeChapter(w), 1, 49, 1));
|
||||
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
|
||||
chapter.setValue(nextFreeChapter(w));
|
||||
PicturePanel pic = new PicturePanel(300, 170);
|
||||
Runnable updatePic = () -> {
|
||||
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
|
||||
@@ -75,7 +77,8 @@ final class NewLevelDialog extends JDialog {
|
||||
row,
|
||||
"TestLevel chapter",
|
||||
chapter,
|
||||
"The level replaces this chapter of world 9 (TestLevel) when it is enabled");
|
||||
"The level replaces this chapter of world 9 (TestLevel) when it is enabled (32–49; 1 is the Test"
|
||||
+ " World's)");
|
||||
JPanel templates = new JPanel();
|
||||
templates.setLayout(new BoxLayout(templates, BoxLayout.Y_AXIS));
|
||||
ButtonGroup g = new ButtonGroup();
|
||||
@@ -161,7 +164,7 @@ final class NewLevelDialog extends JDialog {
|
||||
return row + 1;
|
||||
}
|
||||
|
||||
/** First TestLevel chapter from 2 that no project in the levels folder uses. */
|
||||
/** First usable TestLevel chapter from 32 that no project in the levels folder uses. */
|
||||
static int nextFreeChapter(EditorWindow w) {
|
||||
Set<Integer> used = new HashSet<>();
|
||||
for (Path p : LevelProjects.list(w.env().levelsDir())) {
|
||||
@@ -174,11 +177,7 @@ final class NewLevelDialog extends JDialog {
|
||||
// skip broken files
|
||||
}
|
||||
}
|
||||
int c = 2;
|
||||
while (used.contains(c) && c < 49) {
|
||||
c++;
|
||||
}
|
||||
return c;
|
||||
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
|
||||
}
|
||||
|
||||
Result result() {
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Command;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
@@ -592,7 +593,7 @@ public final class ProjectController {
|
||||
? String.format(
|
||||
Locale.ROOT,
|
||||
"T1L9M%03d",
|
||||
Math.max(1, d.scene().settings().targetChapter()))
|
||||
TestLevelChapters.migrate(d.scene().settings().targetChapter()))
|
||||
: d.base();
|
||||
fc.setSelectedFile(new File(fc.getCurrentDirectory(), stem + ".wad"));
|
||||
if (fc.showSaveDialog(w) != JFileChooser.APPROVE_OPTION) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.eliasmueller.llamamod.format.stpcmerge;
|
||||
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -87,6 +89,41 @@ public final class TextMerge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TEXT chunk {@code orig} with the pages, entries and animations of {@code tex} (usually
|
||||
* {@code Textures.of(orig)} extended by {@link #merge}); palettes and the rest are kept.
|
||||
*/
|
||||
public static byte[] rebuild(byte[] orig, Textures tex) {
|
||||
Textures old = Textures.of(orig);
|
||||
int head = 8 + 8 * old.entries.size();
|
||||
for (Anim a : old.anims) {
|
||||
head += 3 + a.data().length;
|
||||
}
|
||||
TextChunk tc = TextChunk.parse(orig);
|
||||
byte[] rest = Arrays.copyOfRange(tc.tail, head, tc.tail.length);
|
||||
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
|
||||
.order(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 (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();
|
||||
}
|
||||
|
||||
/** Result: map[srcIndex] = target index (−1 = not needed), counts of added items. */
|
||||
public record Result(int[] map, int addedPages, int addedEntries, int addedAnims, int reusedEntries) {}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.eliasmueller.llamamod.mods.arena;
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
|
||||
@@ -106,10 +105,6 @@ final class ArenaImport {
|
||||
StpcMerge.Analysis tgt = new StpcMerge.Analysis(new StpcImage(wad.require("STPC"), lv.wfpc()), troots);
|
||||
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"));
|
||||
@@ -143,40 +138,13 @@ final class ArenaImport {
|
||||
Wad out = new Wad(wad.chunks());
|
||||
out.set("STPC", res.stpc());
|
||||
out.set("MAP", map.build());
|
||||
out.set("TEXT", text(text, tex, origTail));
|
||||
out.set("TEXT", TextMerge.rebuild(text, tex));
|
||||
if (wad.get("SMPC") != null) {
|
||||
out.set("SMPC", SoundMerge.build(snd));
|
||||
}
|
||||
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(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();
|
||||
}
|
||||
|
||||
private static void putI32(byte[] d, int o, int v) {
|
||||
ByteBuffer.wrap(d, o, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(v);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelTally;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
@@ -53,7 +55,29 @@ public final class ArenaMod implements Mod {
|
||||
ModOption.integer(
|
||||
"energy", "Llama energy", "Energy cap of the llama in the arena (5 = normal).", 4, 1, 5),
|
||||
ModOption.integer("coins", "Coins per hit", "Coins that pop out per hit (5 more at the end).", 1, 0, 5),
|
||||
ModOption.integer("chapter", "TestLevel chapter", "Chapter of world 9 the arena replaces.", 8, 2, 8));
|
||||
chapterOption());
|
||||
}
|
||||
|
||||
static final int DEFAULT_CHAPTER = TestLevelChapters.LAST;
|
||||
|
||||
/** TestLevel chapters 32–49 ({@link TestLevelChapters}); a stored 2–8 falls back to 49. */
|
||||
static ModOption chapterOption() {
|
||||
ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1];
|
||||
for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) {
|
||||
cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c));
|
||||
}
|
||||
return ModOption.choice(
|
||||
"chapter",
|
||||
"TestLevel chapter",
|
||||
"Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's).",
|
||||
Integer.toString(DEFAULT_CHAPTER),
|
||||
cs);
|
||||
}
|
||||
|
||||
/** Coins the arena can give beyond its placed ones: hit and death coins of every alligator, exit bonus. */
|
||||
static int dynamicCoins(ArenaScripts.Settings s) {
|
||||
int perGator = (s.lives() - 1) * s.coinsPerHit() + 5;
|
||||
return ArenaScripts.totalAlligators(s.waves()) * perGator + 5;
|
||||
}
|
||||
|
||||
/** After Test World (50) and level projects (60), so a clash with them is reported here. */
|
||||
@@ -95,7 +119,8 @@ public final class ArenaMod implements Mod {
|
||||
}
|
||||
ctx.progress(0.0, "building alligator arena");
|
||||
byte[] base = ctx.read(LevelBuilder.BASE_WAD);
|
||||
byte[] wad = new Arena(settings(ctx))
|
||||
ArenaScripts.Settings settings = settings(ctx);
|
||||
byte[] wad = new Arena(settings)
|
||||
.buildWad(
|
||||
base,
|
||||
rel -> {
|
||||
@@ -106,7 +131,7 @@ public final class ArenaMod implements Mod {
|
||||
}
|
||||
},
|
||||
scriptOpts);
|
||||
ctx.write(target, wad);
|
||||
ctx.write(target, LevelTally.counted(wad, dynamicCoins(settings)));
|
||||
ctx.progress(0.9, "loading picture");
|
||||
ctx.write("T19" + chapter + ".COR", loadingPicture(chapter));
|
||||
ctx.progress(1.0, "arena done");
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod;
|
||||
import com.eliasmueller.llamamod.mods.boottolevel.StartOverride;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -129,14 +130,22 @@ public final class LevelProjectMod implements Mod {
|
||||
if (!info.isNewLevel()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(ModOption.integer(
|
||||
"chapter",
|
||||
"TestLevel chapter",
|
||||
"Chapter of world 9 (TestLevel) the level replaces. Chapter 1 is also used by the Test World mod;"
|
||||
+ " each level needs its own chapter. Unlock it on the Save games tab.",
|
||||
defaultChapter,
|
||||
1,
|
||||
49));
|
||||
return List.of(chapterOption(
|
||||
"Chapter of world 9 (TestLevel) the level replaces: 32–49, or 1 (the Test World's chapter). Each"
|
||||
+ " level needs its own chapter. Unlock it on the Save games tab.",
|
||||
defaultChapter));
|
||||
}
|
||||
|
||||
/**
|
||||
* TestLevel chapter choice: the usable chapters ({@link TestLevelChapters}); a stored value
|
||||
* outside them (2–31 from older versions) falls back to {@code def}.
|
||||
*/
|
||||
private static ModOption chapterOption(String description, int def) {
|
||||
ModOption.Choice[] cs = TestLevelChapters.all().stream()
|
||||
.map(c -> ModOption.of(
|
||||
Integer.toString(c), c == TestLevelChapters.TEST_WORLD ? "1 (Test World)" : "" + c))
|
||||
.toArray(ModOption.Choice[]::new);
|
||||
return ModOption.choice("chapter", "TestLevel chapter", description, Integer.toString(def), cs);
|
||||
}
|
||||
|
||||
/** CD path of the WAD this mod writes. */
|
||||
@@ -160,7 +169,8 @@ public final class LevelProjectMod implements Mod {
|
||||
throw new IOException("Level \"" + name() + "\": " + target + " is already changed by another mod ("
|
||||
+ (info.isNewLevel()
|
||||
? "Test World or another level on TestLevel chapter " + chapter
|
||||
+ "). Choose a free chapter in the options of \"" + name() + "\""
|
||||
+ "). Choose a free chapter in the options of \"" + name()
|
||||
+ "\" or turn one of them off"
|
||||
: "two mods change the same level). Turn one of them off")
|
||||
+ ".");
|
||||
}
|
||||
@@ -174,6 +184,9 @@ public final class LevelProjectMod implements Mod {
|
||||
throw new IOException("Level \"" + name() + "\": " + bad + ". Move the Llama onto walkable ground in"
|
||||
+ " the level editor.");
|
||||
}
|
||||
if (info.isNewLevel()) {
|
||||
wad = LevelTally.counted(wad);
|
||||
}
|
||||
ctx.write(target, wad);
|
||||
if (info.isNewLevel()) {
|
||||
ctx.progress(0.9, "loading picture");
|
||||
|
||||
@@ -5,18 +5,21 @@ import com.eliasmueller.llamamod.editor.io.ProjectInfo;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModSource;
|
||||
import com.eliasmueller.llamamod.platform.AppPaths;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* One mod per {@code .llamalevel} file in {@code <data>/levels/} (category "levels"). New levels
|
||||
* get a default TestLevel chapter: their stored target, or the next free one if another project
|
||||
* already uses it. Temporary play-test copies in {@code levels/.playtest/} are listed too, with
|
||||
* get a default TestLevel chapter: their stored target, or the next free usable one if another
|
||||
* project already uses it or it is not usable ({@link TestLevelChapters}; old projects on 2–31). Temporary play-test copies in {@code levels/.playtest/} are listed too, with
|
||||
* ids {@code playtest-…}.
|
||||
*/
|
||||
public final class LevelProjectSource implements ModSource {
|
||||
@@ -32,57 +35,57 @@ public final class LevelProjectSource implements ModSource {
|
||||
|
||||
@Override
|
||||
public List<Mod> mods() {
|
||||
List<Mod> out = new ArrayList<>();
|
||||
Set<Integer> used = new HashSet<>();
|
||||
List<Path> levels = LevelProjects.list(dir);
|
||||
List<Path> tests = LevelProjects.list(LevelProjects.playtestDir(dir));
|
||||
Set<String> bases = new HashSet<>();
|
||||
levels.forEach(p -> bases.add(LevelProjectMod.baseId(p, false)));
|
||||
tests.forEach(p -> bases.add(LevelProjectMod.baseId(p, true)));
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (Path p : levels) {
|
||||
add(p, false, used, bases, ids, out);
|
||||
List<Entry> all = new ArrayList<>();
|
||||
levels.forEach(p -> read(p, false, all));
|
||||
tests.forEach(p -> read(p, true, all));
|
||||
// usable stored chapters first (first file wins), so a moved old project never displaces them
|
||||
Map<Entry, Integer> chapters = new HashMap<>();
|
||||
Set<Integer> used = new HashSet<>();
|
||||
for (Entry e : all) {
|
||||
int c = e.info.targetChapter();
|
||||
if (e.info.isNewLevel() && !e.playtest && TestLevelChapters.usable(c) && used.add(c)) {
|
||||
chapters.put(e, c);
|
||||
}
|
||||
}
|
||||
for (Path p : tests) {
|
||||
add(p, true, used, bases, ids, out);
|
||||
List<Mod> out = new ArrayList<>();
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (Entry e : all) {
|
||||
int chapter = 0;
|
||||
if (e.info.isNewLevel() && e.playtest) {
|
||||
chapter = TestLevelChapters.migrate(e.info.targetChapter());
|
||||
} else if (e.info.isNewLevel()) {
|
||||
chapter = chapters.computeIfAbsent(e, k -> TestLevelChapters.pick(k.info.targetChapter(), used));
|
||||
used.add(chapter);
|
||||
}
|
||||
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
|
||||
String base = LevelProjectMod.baseId(e.file, e.playtest);
|
||||
String id = base;
|
||||
if (!ids.add(id)) {
|
||||
int i = 2;
|
||||
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
|
||||
i++;
|
||||
}
|
||||
id = base + "-" + i;
|
||||
ids.add(id);
|
||||
}
|
||||
out.add(new LevelProjectMod(e.file, e.data, e.info, chapter, id, e.playtest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void add(
|
||||
Path p, boolean playtest, Set<Integer> used, Set<String> bases, Set<String> ids, List<Mod> out) {
|
||||
byte[] d;
|
||||
ProjectInfo info;
|
||||
private record Entry(Path file, boolean playtest, byte[] data, ProjectInfo info) {}
|
||||
|
||||
private static void read(Path p, boolean playtest, List<Entry> out) {
|
||||
try {
|
||||
d = Files.readAllBytes(p);
|
||||
info = ProjectIO.info(d);
|
||||
byte[] d = Files.readAllBytes(p);
|
||||
out.add(new Entry(p, playtest, d, ProjectIO.info(d)));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return;
|
||||
// unreadable or broken: not listed
|
||||
}
|
||||
int chapter = 0;
|
||||
if (info.isNewLevel() && playtest) {
|
||||
chapter = Math.max(1, Math.min(49, info.targetChapter()));
|
||||
} else if (info.isNewLevel()) {
|
||||
chapter = info.targetChapter();
|
||||
if (chapter < 1 || chapter > 49 || used.contains(chapter)) {
|
||||
chapter = 2;
|
||||
while (used.contains(chapter) && chapter < 49) {
|
||||
chapter++;
|
||||
}
|
||||
}
|
||||
used.add(chapter);
|
||||
}
|
||||
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
|
||||
String base = LevelProjectMod.baseId(p, playtest);
|
||||
String id = base;
|
||||
if (!ids.add(id)) {
|
||||
int i = 2;
|
||||
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
|
||||
i++;
|
||||
}
|
||||
id = base + "-" + i;
|
||||
ids.add(id);
|
||||
}
|
||||
out.add(new LevelProjectMod(p, d, info, chapter, id, playtest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.eliasmueller.llamamod.mods.levels;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The level tally's totals ("Münzen gesammelt n / total", "Geheimnisse entdeckt n / total"): chunk
|
||||
* LNFO = u32 coins, u32 secrets (→ 0x5ff070 / 0x5ff060, FUN_0042ab10). Levels built on a base keep
|
||||
* the base's numbers (Village: 55, 2) unless they are recounted here.
|
||||
*/
|
||||
public final class LevelTally {
|
||||
private LevelTally() {}
|
||||
|
||||
/**
|
||||
* Placed coins (Coin = 1, Coin5 = 5) and Secret strats of a level WAD. Other coin sources
|
||||
* (CoinLine, CoinCircle, enemies' EnemyCoins …) are not counted.
|
||||
*/
|
||||
public static int[] count(byte[] wad) {
|
||||
Level lv = Level.parse(wad);
|
||||
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
|
||||
int coins = n(t, "Coin") + 5 * n(t, "Coin5");
|
||||
return new int[] {coins, n(t, "Secret")};
|
||||
}
|
||||
|
||||
private static int n(Map<String, List<MapChunk.Strat>> t, String type) {
|
||||
List<MapChunk.Strat> l = t.get(type);
|
||||
return l == null ? 0 : l.size();
|
||||
}
|
||||
|
||||
/** {@code wad} with LNFO set to {@link #count} plus {@code extraCoins}; unchanged without LNFO. */
|
||||
public static byte[] counted(byte[] wad, int extraCoins) {
|
||||
int[] c = count(wad);
|
||||
return withTotals(wad, c[0] + extraCoins, c[1]);
|
||||
}
|
||||
|
||||
public static byte[] counted(byte[] wad) {
|
||||
return counted(wad, 0);
|
||||
}
|
||||
|
||||
/** {@code wad} with LNFO = (coins, secrets); unchanged if it has no LNFO chunk. */
|
||||
public static byte[] withTotals(byte[] wad, int coins, int secrets) {
|
||||
Wad w = Wad.parse(wad);
|
||||
byte[] old = w.get("LNFO");
|
||||
if (old == null || old.length < 8) {
|
||||
return wad;
|
||||
}
|
||||
byte[] ln = old.clone();
|
||||
ByteBuffer.wrap(ln).order(ByteOrder.LITTLE_ENDIAN).putInt(0, coins).putInt(4, secrets);
|
||||
w.set("LNFO", ln);
|
||||
return w.toBytes();
|
||||
}
|
||||
}
|
||||
@@ -412,10 +412,6 @@ public final class Gallery {
|
||||
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"));
|
||||
@@ -463,34 +459,7 @@ public final class Gallery {
|
||||
Wad out = new Wad(wad.chunks());
|
||||
out.set("STPC", res.stpc());
|
||||
out.set("MAP", map.build());
|
||||
out.set("TEXT", text(text, tex, origTail));
|
||||
out.set("TEXT", TextMerge.rebuild(text, tex));
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.format.CorEncoder;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelTally;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -59,7 +60,7 @@ public final class TestWorldMod implements Mod {
|
||||
}
|
||||
},
|
||||
Map.of());
|
||||
ctx.write(OUT_WAD, wad);
|
||||
ctx.write(OUT_WAD, LevelTally.counted(wad));
|
||||
ctx.progress(0.9, "loading picture");
|
||||
ctx.write(LOADING_PICTURE, loadingPicture());
|
||||
ctx.progress(1.0, "test world done");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.eliasmueller.llamamod.save;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* TestLevel (world 9) chapters a mod level may occupy. The game has per-chapter arrays only for
|
||||
* worlds 1–8: the coin counter u32 0x584F1C[world·6 + chapter] and the flag byte
|
||||
* 0x584EC8[world·6 + chapter]. For world 9 both run into other globals, which the game zeroes on
|
||||
* level start and overwrites with coins or flags:
|
||||
*
|
||||
* <pre>
|
||||
* coin slot 0x584FF4 + 4c: c=0 counter var 0xC0, 1 unused, 2 counter var 0xBF, 3 HUD flag 0x585000,
|
||||
* 4 var 0x80, 5..8 last played tribe/world/chapter/map (0x585008..14), 9 furthest progress
|
||||
* (0x585018: zeroed on level start, coins make it garbage), 10..21 unused, 22..31 byte table
|
||||
* 0x585024 (strat var 0x93, tribe 1), 32..49 the same table for tribes 2/3 (never used).
|
||||
* flag byte 0x584EFE + c: 0..5 unused, 6..9 language index 0x584F04 (|=2 → wrong texts/crash),
|
||||
* 10..13 option 0x584F08, 14..21 0x584F0C/0x584F10, 22..25 unused, 26..29 option 0x584F18,
|
||||
* 30..49 coin slots of world 0 (never read).
|
||||
* </pre>
|
||||
*
|
||||
* Safe: chapter 1 (Test World) and 32–49.
|
||||
*/
|
||||
public final class TestLevelChapters {
|
||||
private TestLevelChapters() {}
|
||||
|
||||
/** The Test World's chapter. */
|
||||
public static final int TEST_WORLD = 1;
|
||||
|
||||
/** First and last chapter for other mod levels. */
|
||||
public static final int FIRST = 32;
|
||||
|
||||
public static final int LAST = 49;
|
||||
|
||||
public static boolean usable(int chapter) {
|
||||
return chapter == TEST_WORLD || chapter >= FIRST && chapter <= LAST;
|
||||
}
|
||||
|
||||
/** All usable chapters: 1, 32..49. */
|
||||
public static List<Integer> all() {
|
||||
List<Integer> out = new ArrayList<>();
|
||||
out.add(TEST_WORLD);
|
||||
for (int c = FIRST; c <= LAST; c++) {
|
||||
out.add(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable chapter for a stored one: unchanged if usable, else 2..19 → 32..49, 20..31 → 32..43,
|
||||
* anything else → 32 (deterministic, for play-tests and exports of old projects).
|
||||
*/
|
||||
public static int migrate(int chapter) {
|
||||
if (usable(chapter)) {
|
||||
return chapter;
|
||||
}
|
||||
return chapter >= 2 && chapter < FIRST ? FIRST + (chapter - 2) % (LAST - FIRST + 1) : FIRST;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code wanted} if usable and not in {@code used}, else the first free chapter of 32..49; when
|
||||
* all are taken, {@link #migrate}({@code wanted}) (shared: only one of those levels can be on).
|
||||
*/
|
||||
public static int pick(int wanted, Collection<Integer> used) {
|
||||
if (usable(wanted) && !used.contains(wanted)) {
|
||||
return wanted;
|
||||
}
|
||||
for (int c = FIRST; c <= LAST; c++) {
|
||||
if (!used.contains(c)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return migrate(wanted);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user