initial commit: llamamod 0.2.0 – reverse-engineered level formats (wad, map, trak, text, stpc), python tools and notes, java mod manager (iso install, mod pipeline with exe patching, save editor, wine/windows launcher) with mods (test world, debug wireframe, mirror, randomizer, fog, cheats, intro skip, practice, free camera, boot to level, level projects) and the 3d level editor (geometry, objects, cross-world import, environment, textures, play-test, manual)
This commit is contained in:
commit
b36a366240
826 files changed
+105304
No files matched your search
@@ -0,0 +1,42 @@
|
||||
package com.eliasmueller.llamamod;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
/** App version and build id from build-info.properties (generated by the Gradle task buildInfo). */
|
||||
public final class BuildInfo {
|
||||
private static final Properties PROPS = new Properties();
|
||||
|
||||
static {
|
||||
try (InputStream in = BuildInfo.class.getResourceAsStream("build-info.properties")) {
|
||||
if (in != null) {
|
||||
PROPS.load(in);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// unknown build
|
||||
}
|
||||
}
|
||||
|
||||
private BuildInfo() {}
|
||||
|
||||
/** Release version from build.gradle, "dev" when unknown. */
|
||||
public static String version() {
|
||||
return PROPS.getProperty("version", "dev");
|
||||
}
|
||||
|
||||
/** Hash of the compiled classes and resources; changes with every code change. */
|
||||
public static String build() {
|
||||
return PROPS.getProperty("build", "unknown");
|
||||
}
|
||||
|
||||
/** "0.2.0 (build 1a2b3c4d5e)". */
|
||||
public static String display() {
|
||||
return version() + " (build " + build() + ")";
|
||||
}
|
||||
|
||||
/** Identifies the program code for the mod cache ("0.2.0+1a2b3c4d5e"). */
|
||||
public static String cacheId() {
|
||||
return version() + "+" + build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eliasmueller.llamamod;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
/** Build edition: the launcher-only edition ships without the level editor (edition.properties, editor=false). */
|
||||
public final class Edition {
|
||||
private static final Properties PROPS = new Properties();
|
||||
|
||||
static {
|
||||
try (InputStream in = Edition.class.getResourceAsStream("edition.properties")) {
|
||||
if (in != null) {
|
||||
PROPS.load(in);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// default edition
|
||||
}
|
||||
}
|
||||
|
||||
private Edition() {}
|
||||
|
||||
public static boolean editorEnabled() {
|
||||
return !"false".equals(PROPS.getProperty("editor"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.eliasmueller.llamamod;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorApp;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModManager;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import com.eliasmueller.llamamod.mods.ModOptions;
|
||||
import com.eliasmueller.llamamod.mods.ModRegistry;
|
||||
import com.eliasmueller.llamamod.mods.levels.EditorCli;
|
||||
import com.eliasmueller.llamamod.platform.AppPaths;
|
||||
import com.eliasmueller.llamamod.platform.Config;
|
||||
import com.eliasmueller.llamamod.platform.GameLauncher;
|
||||
import com.eliasmueller.llamamod.platform.GameSource;
|
||||
import com.eliasmueller.llamamod.platform.Installer;
|
||||
import com.eliasmueller.llamamod.platform.Os;
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import com.eliasmueller.llamamod.save.Ksv;
|
||||
import com.eliasmueller.llamamod.save.SaveStore;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
import com.eliasmueller.llamamod.ui.App;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.security.Provider;
|
||||
import java.security.Security;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/** Starts the GUI; a few command-line commands exist for scripting and tests. */
|
||||
public final class Main {
|
||||
private Main() {}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// the UI is English; set first (ImageIO already loads AWT) so key names ("Ctrl", not "Strg"), dialog buttons
|
||||
// and number fields (decimal point) match
|
||||
Locale.setDefault(Locale.ENGLISH);
|
||||
if (Os.isWindows()) {
|
||||
// Use the OS RNG (CryptGenRandom) for SecureRandom: the default DRBG seeds itself by
|
||||
// enumerating network interfaces, which crashes under older Wine.
|
||||
Provider mscapi = Security.getProvider("SunMSCAPI");
|
||||
if (mscapi != null) {
|
||||
Security.removeProvider("SunMSCAPI");
|
||||
Security.insertProviderAt(mscapi, 1);
|
||||
}
|
||||
}
|
||||
ImageIO.setUseCache(false);
|
||||
if (args.length > 0 && args[0].equals("--editor")) {
|
||||
if (!Edition.editorEnabled()) {
|
||||
System.err.println("This LlamaMod build has no level editor.");
|
||||
System.exit(1);
|
||||
}
|
||||
EditorApp.main(Arrays.copyOfRange(args, 1, args.length));
|
||||
return;
|
||||
}
|
||||
if (args.length > 0 && (args[0].equals("--version") || args[0].equals("-V"))) {
|
||||
System.out.println(
|
||||
"LlamaMod " + BuildInfo.display() + (Edition.editorEnabled() ? "" : ", launcher edition"));
|
||||
return;
|
||||
}
|
||||
if (args.length == 0 || args[0].equals("--gui")) {
|
||||
App.start();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
System.exit(cli(args));
|
||||
} catch (IOException e) {
|
||||
System.err.println("Error: " + e.getMessage());
|
||||
System.exit(1);
|
||||
} catch (UsageException e) {
|
||||
System.err.println("Error: " + e.getMessage());
|
||||
System.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrong command-line arguments (exit code 2). */
|
||||
static final class UsageException extends IllegalArgumentException {
|
||||
UsageException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private static int intArg(List<String> rest, int i, String what, int min, int max) {
|
||||
if (i >= rest.size()) {
|
||||
throw new UsageException("missing " + what);
|
||||
}
|
||||
int v;
|
||||
try {
|
||||
v = Integer.parseInt(rest.get(i).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new UsageException(what + " must be a number, got \"" + rest.get(i) + "\"");
|
||||
}
|
||||
if (v < min || v > max) {
|
||||
throw new UsageException(what + " must be " + min + "–" + max + ", got " + v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static void requireInstalled(Config cfg) {
|
||||
if (!cfg.installed()) {
|
||||
throw new UsageException("LlamaMod is not set up yet. Run --install <iso|folder> first.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void usage() {
|
||||
System.out.println(
|
||||
"""
|
||||
LlamaMod %s – mod manager for The Emperor's New Groove (PC)
|
||||
|
||||
llamamod start the GUI
|
||||
llamamod --version show the version and build id
|
||||
llamamod --install <iso|folder> set up the game data
|
||||
llamamod --mods list available mods
|
||||
llamamod --apply [id,id,...] [--opt id.key=value ...]
|
||||
apply mods (none = original); options are saved
|
||||
llamamod --launch [--wait] start the game
|
||||
llamamod --registry show the registry values
|
||||
llamamod --saves show save games
|
||||
llamamod --unlock <slot 1-3> <world> <chapter> [--cursor]
|
||||
llamamod --editor [project.llamalevel | t1l1m001] level editor
|
||||
llamamod --editor-build <project.llamalevel> <out.wad> [--cd <dir>]
|
||||
llamamod --editor-import <level.wad|t1l1m001> <project.llamalevel> [--cd <dir>]
|
||||
llamamod --editor-new <project.llamalevel> [base level] [--name <name>] [--cd <dir>]
|
||||
llamamod --import-level <project.llamalevel> (into the level folder, shown as a mod)
|
||||
Data folder: -Dllamamod.data=<folder> or LLAMAMOD_DATA (default %s)
|
||||
"""
|
||||
.formatted(BuildInfo.display(), AppPaths.defaultRoot()));
|
||||
}
|
||||
|
||||
private static final String ANTIVIRUS_HINT =
|
||||
"probably by antivirus software. Run --apply again (same mods) to rebuild it and add the data"
|
||||
+ " folder %s as an exclusion in your antivirus program.";
|
||||
|
||||
private static void checkExe(ModManager mm, AppPaths paths) throws IOException {
|
||||
ModManager.ExeStatus s = mm.checkGameExe();
|
||||
if (s != ModManager.ExeStatus.OK) {
|
||||
throw new IOException("game/groove.exe was removed or changed ("
|
||||
+ s.name().toLowerCase(Locale.ROOT) + ") – " + ANTIVIRUS_HINT.formatted(paths.root()));
|
||||
}
|
||||
}
|
||||
|
||||
static int cli(String[] args) throws Exception {
|
||||
AppPaths paths = AppPaths.resolve();
|
||||
Config cfg = Config.load(paths.config());
|
||||
Progress console = new Progress() {
|
||||
String last = "";
|
||||
long lastT;
|
||||
|
||||
@Override
|
||||
public void update(double f, String m) {
|
||||
long t = System.currentTimeMillis();
|
||||
if (!m.equals(last) && t - lastT > 300 || f >= 1) {
|
||||
System.out.printf(Locale.ROOT, "[%3.0f%%] %s%n", f * 100, m);
|
||||
last = m;
|
||||
lastT = t;
|
||||
}
|
||||
}
|
||||
};
|
||||
List<String> rest = new ArrayList<>(Arrays.asList(args).subList(1, args.length));
|
||||
switch (args[0]) {
|
||||
case "--install" -> {
|
||||
if (rest.size() != 1) {
|
||||
throw new UsageException("--install expects one ISO file or folder");
|
||||
}
|
||||
if (cfg.installed() && new GameLauncher(paths, cfg).isRunning()) {
|
||||
throw new IOException("The game is running. Please quit it before setting up again.");
|
||||
}
|
||||
try (GameSource src = GameSource.open(Path.of(rest.getFirst()))) {
|
||||
new Installer(paths).install(src, cfg, console);
|
||||
}
|
||||
new ModManager(paths, cfg).restoreOriginal(console);
|
||||
System.out.println("Set up in " + paths.root());
|
||||
}
|
||||
case "--mods" -> listMods(paths, cfg);
|
||||
case "--apply" -> {
|
||||
return apply(paths, cfg, rest, console);
|
||||
}
|
||||
case "--launch" -> {
|
||||
requireInstalled(cfg);
|
||||
new SaveStore(paths).migrateNullSaves().forEach(System.out::println);
|
||||
checkExe(new ModManager(paths, cfg), paths);
|
||||
GameLauncher gl = new GameLauncher(paths, cfg);
|
||||
Process p;
|
||||
try {
|
||||
p = gl.launch(console);
|
||||
} catch (GameLauncher.BlockedException e) {
|
||||
throw new IOException(e.getMessage() + " – " + ANTIVIRUS_HINT.formatted(paths.root()));
|
||||
}
|
||||
System.out.println("PID " + p.pid());
|
||||
if (rest.contains("--wait")) {
|
||||
System.out.println("exited: " + p.waitFor());
|
||||
}
|
||||
}
|
||||
case "--registry" -> System.out.print(
|
||||
new GameLauncher(paths, cfg).registry().text());
|
||||
case "--saves" -> listSaves(paths);
|
||||
case "--unlock" -> {
|
||||
requireInstalled(cfg);
|
||||
if (new GameLauncher(paths, cfg).isRunning()) {
|
||||
throw new IOException("The game is running. Please quit it before changing save games.");
|
||||
}
|
||||
int slot = intArg(rest, 0, "slot", 1, SaveStore.SLOTS) - 1;
|
||||
int w = intArg(rest, 1, "world", 1, Worlds.COUNT);
|
||||
int c = intArg(rest, 2, "chapter", 1, Worlds.chapters(w));
|
||||
SaveStore st = new SaveStore(paths);
|
||||
Ksv k = st.load(slot);
|
||||
k.ensureUsed();
|
||||
k.setUnlocked(w, c);
|
||||
if (rest.contains("--cursor")) {
|
||||
k.setCursor(w, c);
|
||||
}
|
||||
Path b = st.save(slot, k);
|
||||
System.out.println("saved" + (b != null ? ", backup: " + b : ""));
|
||||
}
|
||||
case "--editor-build", "--editor-import", "--editor-new", "--import-level" -> {
|
||||
if (!args[0].equals("--import-level") && !Edition.editorEnabled()) {
|
||||
throw new UsageException("This LlamaMod build has no level editor.");
|
||||
}
|
||||
return EditorCli.run(args[0], rest, paths);
|
||||
}
|
||||
default -> {
|
||||
usage();
|
||||
return args[0].equals("--help") || args[0].equals("-h") ? 0 : 2;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void listMods(AppPaths paths, Config cfg) {
|
||||
ModRegistry reg = new ModRegistry();
|
||||
ModManager mm = new ModManager(paths, cfg);
|
||||
for (Mod m : reg.all()) {
|
||||
System.out.printf(
|
||||
"%-20s %-8s %-10s %s – %s%s%n",
|
||||
m.id(),
|
||||
m.version(),
|
||||
m.category(),
|
||||
m.name(),
|
||||
m.description(),
|
||||
mm.modifiesExecutable(m) ? " [modifies groove.exe]" : "");
|
||||
Map<String, String> values = ModOptions.resolve(m, cfg);
|
||||
for (ModOption o : m.options()) {
|
||||
String range =
|
||||
switch (o.type()) {
|
||||
case INT -> " " + o.min() + ".." + o.max();
|
||||
case CHOICE -> " "
|
||||
+ o.choices().stream()
|
||||
.map(ModOption.Choice::value)
|
||||
.toList();
|
||||
default -> "";
|
||||
};
|
||||
System.out.printf(
|
||||
" --opt %s.%s=%s (%s%s, default %s) %s%n",
|
||||
m.id(),
|
||||
o.key(),
|
||||
values.get(o.key()),
|
||||
o.type().name().toLowerCase(Locale.ROOT),
|
||||
range,
|
||||
o.defaultValue().isEmpty() ? "random" : o.defaultValue(),
|
||||
o.label());
|
||||
}
|
||||
}
|
||||
reg.problems().forEach(p -> System.out.println("! " + p));
|
||||
}
|
||||
|
||||
/** {@code --apply [id,id,...] [--opt id.key=value ...]}; returns the exit code. */
|
||||
private static int apply(AppPaths paths, Config cfg, List<String> rest, Progress console) throws IOException {
|
||||
requireInstalled(cfg);
|
||||
ModRegistry reg = new ModRegistry();
|
||||
List<Mod> mods = new ArrayList<>();
|
||||
List<String> opts = new ArrayList<>();
|
||||
for (int i = 0; i < rest.size(); i++) {
|
||||
String a = rest.get(i);
|
||||
if (a.equals("--opt") && i + 1 < rest.size()) {
|
||||
opts.add(rest.get(++i));
|
||||
} else if (a.startsWith("--opt=")) {
|
||||
opts.add(a.substring(6));
|
||||
} else if (mods.isEmpty() && !a.startsWith("--")) {
|
||||
for (String id : a.split(",")) {
|
||||
if (id.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Mod m = reg.get(id.trim());
|
||||
if (m == null) {
|
||||
System.err.println("Unknown mod: " + id);
|
||||
return 2;
|
||||
}
|
||||
mods.add(m);
|
||||
}
|
||||
} else {
|
||||
System.err.println("Unexpected argument: " + a);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
for (String o : opts) {
|
||||
int dot = o.indexOf('.');
|
||||
int eq = o.indexOf('=');
|
||||
if (dot <= 0 || eq < dot) {
|
||||
System.err.println("--opt expects id.key=value, got: " + o);
|
||||
return 2;
|
||||
}
|
||||
Mod m = reg.get(o.substring(0, dot));
|
||||
if (m == null) {
|
||||
System.err.println("Unknown mod in --opt: " + o.substring(0, dot));
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
ModOptions.set(cfg, m, o.substring(dot + 1, eq), o.substring(eq + 1));
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList());
|
||||
ModManager mm = new ModManager(paths, cfg);
|
||||
ModManager.Result r = mm.apply(mods, console);
|
||||
System.out.println(
|
||||
"Active: " + r.modIds() + ", changed files: " + r.changedFiles() + ", cached: " + r.cachedMods());
|
||||
r.notes().forEach(n -> System.out.println(" " + n));
|
||||
checkExe(mm, paths);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void listSaves(AppPaths paths) {
|
||||
SaveStore st = new SaveStore(paths);
|
||||
for (int s = 0; s < SaveStore.SLOTS; s++) {
|
||||
Ksv k = st.load(s);
|
||||
if (!k.used()) {
|
||||
System.out.printf("Slot %d: empty%n", s + 1);
|
||||
continue;
|
||||
}
|
||||
System.out.printf(
|
||||
"Slot %d: last %s %d, unlocked up to %s %d, map cursor %s %d, lives %d, gold coins %d%n",
|
||||
s + 1,
|
||||
Worlds.name(k.lastWorld()),
|
||||
k.lastChapter(),
|
||||
Worlds.name(k.maxWorld()),
|
||||
k.maxChapter(),
|
||||
Worlds.name(k.cursorWorld()),
|
||||
k.cursorChapter(),
|
||||
k.lives(),
|
||||
k.goldCoins());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Source of original game files (the extracted CD): base level WADs and other levels for foreign
|
||||
* object types. Paths are CD-relative with '/', matched case-insensitively.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface Bases {
|
||||
/** File content, or {@code null} if the CD has no such file. */
|
||||
byte[] file(String relPath) throws IOException;
|
||||
|
||||
default byte[] wad(String base) throws IOException {
|
||||
byte[] d = file("wads/" + base.toLowerCase(Locale.ROOT) + ".wad");
|
||||
if (d == null) {
|
||||
throw new IOException("base level " + base + " not found on the game CD");
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Same as {@link #file} with IO errors as {@code null} (for optional lookups). */
|
||||
default byte[] fileOrNull(String relPath) {
|
||||
try {
|
||||
return file(relPath);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Bases fromCd(Path cd) {
|
||||
return rel -> {
|
||||
Path p = resolveIgnoreCase(cd, rel);
|
||||
return p == null ? null : Files.readAllBytes(p);
|
||||
};
|
||||
}
|
||||
|
||||
static Path resolveIgnoreCase(Path root, String rel) throws IOException {
|
||||
Path cur = root;
|
||||
for (String part : rel.split("/")) {
|
||||
Path direct = cur.resolve(part);
|
||||
if (Files.exists(direct)) {
|
||||
cur = direct;
|
||||
continue;
|
||||
}
|
||||
if (!Files.isDirectory(cur)) {
|
||||
return null;
|
||||
}
|
||||
Path found = null;
|
||||
try (Stream<Path> s = Files.list(cur)) {
|
||||
for (Path c : s.toList()) {
|
||||
if (c.getFileName().toString().equalsIgnoreCase(part)) {
|
||||
found = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (UncheckedIOException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
if (found == null) {
|
||||
return null;
|
||||
}
|
||||
cur = found;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.BrushKind;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.MeshAsset;
|
||||
import com.eliasmueller.llamamod.editor.model.MeshData;
|
||||
import com.eliasmueller.llamamod.editor.model.RawChunk;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureEntry;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.format.Collision;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Model;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Trak;
|
||||
import com.eliasmueller.llamamod.format.Vertex;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.levelbuild.Opts;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Brushes and mesh placements → generated tile models, tiles (with baked vertex colours and
|
||||
* collision) and textures, through {@link LevelBuilder} (same primitives as levels/testwelt.py).
|
||||
* Death planes (SEA with a death plane, KILL_PLANE) are placed in every cell of their area that is
|
||||
* not completely covered by floor collision. Generated nodes are not part of the scene; their ids
|
||||
* start with "gen:".
|
||||
*/
|
||||
public final class BrushCompiler {
|
||||
/** Local y of the death plane in its model (as the village's model 0). */
|
||||
static final double KILL_LOCAL_Y = -2.75;
|
||||
|
||||
static final int KILL_MATERIAL = 30;
|
||||
static final int MESH_TEXTURE_FLAGS = 0x104;
|
||||
|
||||
private BrushCompiler() {}
|
||||
|
||||
public record Output(
|
||||
List<TileModel> models, List<Tile> tiles, List<TexturePage> pages, List<TextureEntry> textures) {
|
||||
static final Output EMPTY = new Output(List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
/** Kill area {y, x0, z0, x1, z1} in game space. */
|
||||
private record Kill(double y, double x0, double z0, double x1, double z1) {}
|
||||
|
||||
/** One mesh triangle as a builder quad. */
|
||||
private record MeshQuad(double[][] points, String tex, double[] tint, boolean doubleSided) {}
|
||||
|
||||
/** Most quads one brush may split into (keeps the live preview responsive). */
|
||||
static final long MAX_PIECES = 200_000;
|
||||
|
||||
/**
|
||||
* Parameter values the builder cannot handle (they would hang it, run out of memory or index
|
||||
* past an array): non-numbers, tile sizes / spacings near zero, too many steps, arrays of the
|
||||
* wrong length. null if fine.
|
||||
*/
|
||||
public static String paramProblem(Brush b) {
|
||||
BrushKind k = b.brushKind();
|
||||
for (BrushKind.Param p : k.params()) {
|
||||
Object v = b.get(p.name());
|
||||
if (v instanceof Double d && !Double.isFinite(d)) {
|
||||
return p.label() + " is not a number";
|
||||
}
|
||||
if (v instanceof double[] a) {
|
||||
for (double x : a) {
|
||||
if (!Double.isFinite(x)) {
|
||||
return p.label() + " contains a value that is not a number";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String n : List.of("tint", "rotation", "stretch", "normal")) {
|
||||
if (has(k, n) && b.get(n) instanceof double[] a && a.length != 3) {
|
||||
return k.param(n).label() + " needs 3 values";
|
||||
}
|
||||
}
|
||||
if (has(k, "killArea") && "area".equals(b.get("kill")) && b.a("killArea").length != 4) {
|
||||
return k.param("killArea").label() + " needs 4 values";
|
||||
}
|
||||
if (has(k, "step")) {
|
||||
double step = b.d("step");
|
||||
if (step < 0.125 || step > 64) {
|
||||
return "tile size must be 0.125..64";
|
||||
}
|
||||
if (has(k, "x0")) {
|
||||
double nx = Math.abs(b.d("x1") - b.d("x0")) / step + 2, nz = Math.abs(b.d("z1") - b.d("z0")) / step + 2;
|
||||
if (nx * nz > MAX_PIECES) {
|
||||
return "too many tiles (" + (long) (nx * nz) + "): use a larger tile size";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (has(k, "postsEvery") && b.d("postsEvery") < 0.1) {
|
||||
return "post spacing must be at least 0.1";
|
||||
}
|
||||
if (has(k, "steps") && (b.i("steps") < 1 || b.i("steps") > 256)) {
|
||||
return "steps must be 1..256";
|
||||
}
|
||||
for (String n : List.of("thick", "size", "radius", "height", "crown", "trunkSize", "w", "d")) {
|
||||
if (has(k, n) && (b.d(n) < 0 || b.d(n) > 256)) {
|
||||
return k.param(n).label() + " must be 0..256";
|
||||
}
|
||||
}
|
||||
if (has(k, "texSize") && (b.i("texSize") < 16 || b.i("texSize") > 256)) {
|
||||
return "texture size must be 16..256";
|
||||
}
|
||||
if (has(k, "uvSteps") && (b.i("uvSteps") < 1 || b.i("uvSteps") > 256)) {
|
||||
return "UV precision must be 1..256";
|
||||
}
|
||||
if (k == BrushKind.FENCE && b.a("points").length / 2 > 1024
|
||||
|| (k == BrushKind.QUAD || k == BrushKind.COLLIDER) && b.a("points").length > 12) {
|
||||
return "too many points";
|
||||
}
|
||||
if (k == BrushKind.FENCE) {
|
||||
double len = 0;
|
||||
double[] a = b.a("points");
|
||||
for (int i = 0; i + 3 < a.length; i += 2) {
|
||||
len += Math.hypot(a[i + 2] - a[i], a[i + 3] - a[i + 1]);
|
||||
}
|
||||
if (len / b.d("postsEvery") > MAX_PIECES) {
|
||||
return "too many fence posts: use a larger post spacing";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean has(BrushKind k, String param) {
|
||||
for (BrushKind.Param p : k.params()) {
|
||||
if (p.name().equals(param)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Cells a brush may reach beyond the grid before the compiler skips it (memory guard). */
|
||||
static final double FAR_MARGIN = 64;
|
||||
|
||||
/** Highest |y| (cells) of brush geometry. */
|
||||
static final double MAX_HEIGHT = 4096;
|
||||
|
||||
/**
|
||||
* Why a brush's bounds are unusable: non-finite coordinates, more than margin cells outside
|
||||
* the w × h grid, or beyond ±{@link #MAX_HEIGHT}; null if fine.
|
||||
*/
|
||||
public static String extentProblem(Brush b, int w, int h, double margin) {
|
||||
double[] bb = b.bounds();
|
||||
for (double v : bb) {
|
||||
if (Double.isNaN(v)) {
|
||||
return "coordinates are not numbers";
|
||||
}
|
||||
}
|
||||
if (!Double.isFinite(bb[0]) && !Double.isFinite(bb[3])) {
|
||||
return null;
|
||||
}
|
||||
for (double v : bb) {
|
||||
if (!Double.isFinite(v)) {
|
||||
return "coordinates are infinite";
|
||||
}
|
||||
}
|
||||
if (bb[0] < -margin || bb[2] < -margin || bb[3] > w + margin || bb[5] > h + margin) {
|
||||
return String.format(
|
||||
java.util.Locale.ROOT,
|
||||
"extends outside the %d×%d grid (x %.1f..%.1f, z %.1f..%.1f)",
|
||||
w,
|
||||
h,
|
||||
bb[0],
|
||||
bb[3],
|
||||
bb[2],
|
||||
bb[5]);
|
||||
}
|
||||
if (bb[1] < -MAX_HEIGHT || bb[4] > MAX_HEIGHT) {
|
||||
return String.format(java.util.Locale.ROOT, "height %.1f..%.1f is out of range", bb[1], bb[4]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** {@link #paramProblem}, then {@link #extentProblem}; null if the brush can be built. */
|
||||
static String problem(Brush b, int w, int h, double margin) {
|
||||
String bad = paramProblem(b);
|
||||
return bad != null ? bad : extentProblem(b, w, h, margin);
|
||||
}
|
||||
|
||||
/** Brushes near the cell named in a "geometry outside the grid: cell x,z" error (to find them). */
|
||||
static String culprits(List<Brush> brushes, String msg) {
|
||||
java.util.regex.Matcher m =
|
||||
java.util.regex.Pattern.compile("cell (-?\\d+),(-?\\d+)").matcher(msg == null ? "" : msg);
|
||||
if (!m.find()) {
|
||||
return "";
|
||||
}
|
||||
int cx = Integer.parseInt(m.group(1));
|
||||
int cz = Integer.parseInt(m.group(2));
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (Brush b : brushes) {
|
||||
double[] bb = b.bounds();
|
||||
if (Double.isFinite(bb[0])
|
||||
&& bb[0] - 1 < cx + 1
|
||||
&& bb[3] + 1 > cx
|
||||
&& bb[2] - 1 < cz + 1
|
||||
&& bb[5] + 1 > cz) {
|
||||
ids.add(b.id());
|
||||
}
|
||||
}
|
||||
return ids.isEmpty()
|
||||
? ""
|
||||
: " – move " + String.join(", ", ids.subList(0, Math.min(5, ids.size()))) + " into the grid";
|
||||
}
|
||||
|
||||
public static Output compile(Scene s, List<String> warn) {
|
||||
List<Brush> brushes = s.brushes().stream().filter(Brush::enabled).toList();
|
||||
if (brushes.isEmpty()) {
|
||||
return Output.EMPTY;
|
||||
}
|
||||
LevelSettings st = s.settings();
|
||||
List<TexturePage> genPages = new ArrayList<>();
|
||||
List<TextureEntry> genTex = new ArrayList<>();
|
||||
MeshTextures mt = new MeshTextures(s, genPages, genTex);
|
||||
// mesh materials first: their pages and entries must exist in the TEXT the builder sees
|
||||
Map<Brush, List<MeshQuad>> meshQuads = new HashMap<>();
|
||||
for (Brush b : brushes) {
|
||||
if (b.brushKind() == BrushKind.MESH) {
|
||||
meshQuads.put(b, mt.triangles(b, warn));
|
||||
}
|
||||
}
|
||||
List<TexturePage> pages = new ArrayList<>(s.pages());
|
||||
pages.addAll(genPages);
|
||||
List<TextureEntry> entries = new ArrayList<>(s.textures());
|
||||
entries.addAll(genTex);
|
||||
byte[] text = ChunkWriter.text(s, pages, entries);
|
||||
LevelBuilder lb = new LevelBuilder(syntheticBase(s, text), rel -> null, st.levelName(), st.seed(), Map.of());
|
||||
LevelBuilder.Sun sun = lb.sun;
|
||||
sun.azimuth = st.sunAzimuth();
|
||||
sun.elevation = st.sunElevation();
|
||||
sun.strength = st.sunStrength();
|
||||
sun.color = st.sunColor().clone();
|
||||
sun.sky = st.sky().clone();
|
||||
sun.ground = st.ground().clone();
|
||||
sun.ao = st.ao();
|
||||
sun.aoRadius = st.aoRadius();
|
||||
sun.shadow = st.shadow();
|
||||
List<Kill> kills = new ArrayList<>();
|
||||
for (Brush b : brushes) {
|
||||
try {
|
||||
String bad = problem(b, st.width(), st.height(), FAR_MARGIN);
|
||||
if (bad != null) {
|
||||
throw new IllegalArgumentException(bad);
|
||||
}
|
||||
int q0 = lb.quadCount();
|
||||
int s0 = lb.solidCount();
|
||||
replay(lb, b, kills, meshQuads.get(b));
|
||||
postProcess(lb, b, q0, s0);
|
||||
} catch (IllegalArgumentException e) {
|
||||
warn.add(b.label() + ": " + e.getMessage() + " – skipped");
|
||||
} catch (RuntimeException e) {
|
||||
// one broken brush must not take all the others with it (live preview and build)
|
||||
warn.add(b.label() + ": cannot be built (" + e + ") – skipped");
|
||||
}
|
||||
}
|
||||
byte[] wad;
|
||||
try {
|
||||
wad = lb.build();
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalStateException("brushes: " + e.getMessage() + culprits(brushes, e.getMessage()), e);
|
||||
}
|
||||
Level lv = Level.parse(wad);
|
||||
List<TileModel> models = new ArrayList<>();
|
||||
for (int i = 0; i < lv.models().size(); i++) {
|
||||
TileModel m = WadImporter.tileModel("gen:model:" + i, lv.models().get(i));
|
||||
m.setRaw("header", null);
|
||||
models.add(m);
|
||||
}
|
||||
List<Tile> tiles = new ArrayList<>();
|
||||
MapChunk map = lv.map();
|
||||
List<MapChunk.TileColors> cols = map.tileColors(Trak.vertCounts(lv.models()));
|
||||
for (int i = 0; i < map.tiles.size(); i++) {
|
||||
MapChunk.Tile mt2 = map.tiles.get(i);
|
||||
Tile t = new Tile("gen:tile:" + i);
|
||||
t.setRaw("x", mt2.x());
|
||||
t.setRaw("y", mt2.y());
|
||||
t.setRaw("z", mt2.z());
|
||||
t.setRaw("rot", mt2.rot());
|
||||
t.setRaw("model", models.get(mt2.model()));
|
||||
t.setRaw("colors", cols.get(i).frames().getFirst());
|
||||
tiles.add(t);
|
||||
}
|
||||
List<TextChunk.TextureEntry> outTex = TextChunk.textureEntries(lv.wad().require("TEXT"));
|
||||
for (int i = entries.size(); i < outTex.size(); i++) {
|
||||
genTex.add(WadImporter.textureEntry("gen:tex:" + i, outTex.get(i)));
|
||||
}
|
||||
if (!kills.isEmpty()) {
|
||||
addKillPlanes(s, kills, models, tiles);
|
||||
}
|
||||
return new Output(models, tiles, genPages, genTex);
|
||||
}
|
||||
|
||||
/** WAD the builder takes object code, textures and grid size from (no tiles). */
|
||||
private static byte[] syntheticBase(Scene s, byte[] text) {
|
||||
List<Wad.Chunk> ch = new ArrayList<>();
|
||||
for (RawChunk c : s.chunks()) {
|
||||
if (c.tag().equals("WFPC") || c.tag().equals("STPC")) {
|
||||
ch.add(new Wad.Chunk(c.tag(), c.data()));
|
||||
}
|
||||
}
|
||||
ch.add(new Wad.Chunk("TEXT", text));
|
||||
ch.add(new Wad.Chunk("TRAK", Trak.build(List.of())));
|
||||
ch.add(new Wad.Chunk("MAP ", ChunkWriter.map(s, List.of(), List.of())));
|
||||
ch.add(new Wad.Chunk("END ", new byte[0]));
|
||||
return new Wad(ch).toBytes();
|
||||
}
|
||||
|
||||
private static Opts opts(Brush b) {
|
||||
Opts o = Opts.o();
|
||||
for (BrushKind.Param p : b.brushKind().params()) {
|
||||
Object v = b.get(p.name());
|
||||
if (v == null) {
|
||||
continue;
|
||||
}
|
||||
switch (p.name()) {
|
||||
case "top" -> o.top((String) v);
|
||||
case "side" -> o.side((String) v);
|
||||
case "sideTop" -> o.sideTop((String) v);
|
||||
case "bottom" -> o.bottom((String) v);
|
||||
case "sides" -> o.sides((String) v);
|
||||
case "coll" -> o.coll((Boolean) v);
|
||||
case "sideColl" -> o.sideColl((Boolean) v);
|
||||
case "mat" -> o.mat((Integer) v);
|
||||
case "solid" -> o.solid((Boolean) v);
|
||||
case "texRot" -> o.rot((Integer) v);
|
||||
case "step" -> o.step((Double) v);
|
||||
case "thick" -> o.thick((Double) v);
|
||||
case "base" -> o.base((Double) v);
|
||||
case "walkable" -> o.walkable((Boolean) v);
|
||||
case "tint" -> {
|
||||
double[] t = (double[]) v;
|
||||
o.tint(t[0], t[1], t[2]);
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
private static double[][] points(double[] a) {
|
||||
if (a.length != 9 && a.length != 12) {
|
||||
throw new IllegalArgumentException("3 or 4 corners (x, y, z) needed");
|
||||
}
|
||||
double[][] p = new double[a.length / 3][];
|
||||
for (int i = 0; i < p.length; i++) {
|
||||
p[i] = new double[] {a[3 * i], a[3 * i + 1], a[3 * i + 2]};
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private static void replay(LevelBuilder lb, Brush b, List<Kill> kills, List<MeshQuad> mesh) {
|
||||
Opts o = opts(b);
|
||||
switch (b.brushKind()) {
|
||||
case FLOOR -> {
|
||||
if (b.i("mat") == 0) {
|
||||
lb.floor(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y"), b.s("tex"), o);
|
||||
} else {
|
||||
// same surface as floor(), with a collision material
|
||||
o.top(b.s("tex")).side(Opts.NONE).solid(false);
|
||||
lb.box(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y"), b.d("y"), o);
|
||||
}
|
||||
}
|
||||
case BOX -> lb.box(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y0"), b.d("y1"), o);
|
||||
case PLATFORM -> lb.platform(b.d("x"), b.d("z"), b.d("w"), b.d("d"), b.d("y"), o);
|
||||
case PILLAR -> lb.pillar(b.d("x"), b.d("z"), b.d("y0"), b.d("y1"), b.d("size"), o);
|
||||
case CYLINDER -> lb.cylinder(b.d("x"), b.d("z"), b.d("y0"), b.d("y1"), b.d("radius"), b.i("segments"), o);
|
||||
case RAMP -> lb.ramp(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("ya"), b.d("yb"), b.s("direction"), o);
|
||||
case STAIRS -> lb.stairs(
|
||||
b.d("x0"),
|
||||
b.d("z0"),
|
||||
b.d("x1"),
|
||||
b.d("z1"),
|
||||
b.d("y0"),
|
||||
b.d("y1"),
|
||||
b.i("steps"),
|
||||
b.s("direction"),
|
||||
o);
|
||||
case FENCE -> {
|
||||
double[] a = b.a("points");
|
||||
List<double[]> pts = new ArrayList<>();
|
||||
for (int i = 0; i + 1 < a.length; i += 2) {
|
||||
pts.add(new double[] {a[i], a[i + 1]});
|
||||
}
|
||||
lb.fence(pts, b.d("y"), b.d("height"), b.d("thick"), b.s("post"), b.s("rail"), b.d("postsEvery"));
|
||||
}
|
||||
case TREE -> lb.tree(
|
||||
b.d("x"),
|
||||
b.d("z"),
|
||||
b.d("y"),
|
||||
b.d("height"),
|
||||
b.d("crown"),
|
||||
b.s("trunk"),
|
||||
b.s("leaves"),
|
||||
b.d("trunkSize"));
|
||||
case SEA -> {
|
||||
double y = b.d("y");
|
||||
lb.sea(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), y, b.s("tex"), new double[0], b.d("step"));
|
||||
switch (b.s("kill")) {
|
||||
case "whole" -> kills.add(new Kill(y + 0.05, b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1")));
|
||||
case "area" -> {
|
||||
double[] k = b.a("killArea");
|
||||
kills.add(new Kill(y + 0.05, k[0], k[1], k[2], k[3]));
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
case KILL_PLANE -> kills.add(new Kill(b.d("y"), b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1")));
|
||||
case QUAD -> {
|
||||
double[] t = b.a("tint");
|
||||
lb.quad(
|
||||
points(b.a("points")),
|
||||
b.s("tex"),
|
||||
b.b("coll"),
|
||||
b.i("mat"),
|
||||
t.clone(),
|
||||
b.i("texRot"),
|
||||
b.b("shade"),
|
||||
b.b("doubleSided"),
|
||||
b.b("render"));
|
||||
}
|
||||
case COLLIDER -> lb.collider(
|
||||
points(b.a("points")), b.i("mat"), b.a("normal").clone());
|
||||
case MESH -> {
|
||||
if (mesh != null) {
|
||||
for (MeshQuad q : mesh) {
|
||||
lb.quad(
|
||||
q.points(),
|
||||
q.tex(),
|
||||
b.b("coll"),
|
||||
b.i("mat"),
|
||||
q.tint(),
|
||||
0,
|
||||
b.b("shade"),
|
||||
q.doubleSided(),
|
||||
true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ generic brush parameters
|
||||
|
||||
/** Face class (index in {@link BrushKind#FACES}) of a quad from its front normal (game space). */
|
||||
public static int faceClass(double[] n) {
|
||||
if (n[1] >= 0.35) {
|
||||
return 0;
|
||||
}
|
||||
if (n[1] <= -0.35) {
|
||||
return 1;
|
||||
}
|
||||
if (Math.abs(n[0]) > Math.abs(n[2])) {
|
||||
return n[0] > 0 ? 3 : 5;
|
||||
}
|
||||
return n[2] > 0 ? 4 : 2;
|
||||
}
|
||||
|
||||
/** Front normal (game space) of builder quad corners. */
|
||||
public static double[] frontNormal(double[][] p) {
|
||||
double[] a = {p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]};
|
||||
double[] c = {p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]};
|
||||
// builder corners are wound for export space (z mirrored): the cross product points inwards
|
||||
double[] n = {-(a[1] * c[2] - a[2] * c[1]), -(a[2] * c[0] - a[0] * c[2]), -(a[0] * c[1] - a[1] * c[0])};
|
||||
double l = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
|
||||
return l < 1e-12 ? new double[] {0, 1, 0} : new double[] {n[0] / l, n[1] / l, n[2] / l};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the generic parameters of transformable kinds to the quads/shadow boxes a brush added:
|
||||
* per-face collision and material, visibility and the rotation about the pivot.
|
||||
*/
|
||||
static void postProcess(LevelBuilder lb, Brush b, int q0, int s0) {
|
||||
if (!b.brushKind().transformable()) {
|
||||
return;
|
||||
}
|
||||
String cf = b.s("collFaces");
|
||||
int[] fm = b.get("faceMats") instanceof int[] a && a.length == 6 ? a : null;
|
||||
boolean visible = b.visible();
|
||||
boolean perFace =
|
||||
!BrushKind.FACES.equals(cf) || fm != null && Arrays.stream(fm).anyMatch(m -> m >= 0);
|
||||
boolean rot = b.hasRotation();
|
||||
if (!perFace && visible && !rot) {
|
||||
return;
|
||||
}
|
||||
double[] pv = b.pivot();
|
||||
double[] m = Brush.rotationMatrix(b.rotation());
|
||||
lb.editQuads(q0, e -> {
|
||||
if (perFace) {
|
||||
int k = faceClass(frontNormal(e.points));
|
||||
char c = BrushKind.FACES.charAt(k);
|
||||
if (cf.indexOf(c) < 0) {
|
||||
e.coll = false;
|
||||
}
|
||||
if (fm != null && fm[k] >= 0) {
|
||||
e.mat = fm[k];
|
||||
}
|
||||
}
|
||||
if (!visible) {
|
||||
e.render = false;
|
||||
}
|
||||
if (rot) {
|
||||
for (int i = 0; i < e.points.length; i++) {
|
||||
e.points[i] = Brush.rotate(m, pv, e.points[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
lb.editSolids(s0, a -> {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
double[] o = {
|
||||
Double.MAX_VALUE,
|
||||
Double.MAX_VALUE,
|
||||
Double.MAX_VALUE,
|
||||
-Double.MAX_VALUE,
|
||||
-Double.MAX_VALUE,
|
||||
-Double.MAX_VALUE
|
||||
};
|
||||
for (int c = 0; c < 8; c++) {
|
||||
double[] p = {a[(c & 1) != 0 ? 3 : 0], a[(c & 2) != 0 ? 4 : 1], a[(c & 4) != 0 ? 5 : 2]};
|
||||
double[] w = rot ? Brush.rotate(m, pv, p) : p;
|
||||
for (int k = 0; k < 3; k++) {
|
||||
o[k] = Math.min(o[k], w[k]);
|
||||
o[k + 3] = Math.max(o[k + 3], w[k]);
|
||||
}
|
||||
}
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ death planes
|
||||
|
||||
static Model killModel() {
|
||||
Model m = new Model();
|
||||
double y = KILL_LOCAL_Y;
|
||||
double[][] c = {{-0.5, y, -0.5}, {-0.5, y, 0.5}, {0.5, y, 0.5}, {0.5, y, -0.5}};
|
||||
for (double[] p : c) {
|
||||
m.verts.add(new Vertex(p[0], p[1], p[2], 0, 1, 0));
|
||||
}
|
||||
m.coll.get(0).add(Collision.fromPolygon(Arrays.asList(c), KILL_MATERIAL, null));
|
||||
m.dirty = true;
|
||||
return m;
|
||||
}
|
||||
|
||||
private static void addKillPlanes(Scene s, List<Kill> kills, List<TileModel> models, List<Tile> tiles) {
|
||||
Map<Long, Double> floor = new HashMap<>();
|
||||
List<Tile> all =
|
||||
new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList());
|
||||
all.addAll(tiles);
|
||||
for (Tile t : all) {
|
||||
int cx = t.cellX();
|
||||
int cz = t.cellZ();
|
||||
for (TileModel.PlacedPolygon p : t.worldCollisionPolygons()) {
|
||||
if (p.material() == KILL_MATERIAL || Math.abs(p.normal()[1]) < 0.7) {
|
||||
continue;
|
||||
}
|
||||
double[][] xz = new double[p.corners().length][];
|
||||
for (int i = 0; i < xz.length; i++) {
|
||||
xz[i] = new double[] {p.corners()[i][0], -p.corners()[i][2]};
|
||||
}
|
||||
double a = clippedArea(xz, cx, cz);
|
||||
if (a > 0) {
|
||||
floor.merge(cellKey(cx, cz), a, Double::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
TileModel km = WadImporter.tileModel("gen:model:kill", killModel());
|
||||
km.setRaw("header", null);
|
||||
models.add(km);
|
||||
int[] kc = new int[km.vertexCount()];
|
||||
Arrays.fill(kc, 0x01404040);
|
||||
int w = s.settings().width();
|
||||
int h = s.settings().height();
|
||||
Set<Long> placed = new HashSet<>();
|
||||
int n = 0;
|
||||
for (Kill k : kills) {
|
||||
for (long cx = Math.max(0, (long) Math.floor(k.x0)); cx < Math.min(w, (long) Math.ceil(k.x1)); cx++) {
|
||||
for (long cz = Math.max(0, (long) Math.floor(k.z0)); cz < Math.min(h, (long) Math.ceil(k.z1)); cz++) {
|
||||
long key = cellKey(cx, cz);
|
||||
if (floor.getOrDefault(key, 0.0) > 0.999 || !placed.add(key)) {
|
||||
continue;
|
||||
}
|
||||
Tile t = new Tile("gen:kill:" + n++);
|
||||
t.setRaw("x", cx + 0.5);
|
||||
t.setRaw("y", (double) (float) (k.y - KILL_LOCAL_Y));
|
||||
t.setRaw("z", -(cz + 0.5));
|
||||
t.setRaw("rot", 0);
|
||||
t.setRaw("model", km);
|
||||
t.setRaw("colors", kc.clone());
|
||||
tiles.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long cellKey(long cx, long cz) {
|
||||
return (cx << 32) ^ (cz & 0xffffffffL);
|
||||
}
|
||||
|
||||
/** Area of a polygon (x, z) clipped to the cell [cx, cx+1] × [cz, cz+1]. */
|
||||
static double clippedArea(double[][] p, int cx, int cz) {
|
||||
List<double[]> poly = new ArrayList<>(Arrays.asList(p));
|
||||
double[][] edges = {{1, 0, cx}, {-1, 0, -(cx + 1)}, {0, 1, cz}, {0, -1, -(cz + 1)}};
|
||||
for (double[] e : edges) {
|
||||
List<double[]> out = new ArrayList<>();
|
||||
for (int i = 0; i < poly.size(); i++) {
|
||||
double[] a = poly.get(i);
|
||||
double[] b = poly.get((i + 1) % poly.size());
|
||||
double da = e[0] * a[0] + e[1] * a[1] - e[2];
|
||||
double db = e[0] * b[0] + e[1] * b[1] - e[2];
|
||||
if (da >= 0) {
|
||||
out.add(a);
|
||||
}
|
||||
if ((da >= 0) != (db >= 0)) {
|
||||
double t = da / (da - db);
|
||||
out.add(new double[] {a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t});
|
||||
}
|
||||
}
|
||||
poly = out;
|
||||
if (poly.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
double a = 0;
|
||||
for (int i = 0; i < poly.size(); i++) {
|
||||
double[] u = poly.get(i);
|
||||
double[] v = poly.get((i + 1) % poly.size());
|
||||
a += u[0] * v[1] - v[0] * u[1];
|
||||
}
|
||||
return Math.abs(a) / 2;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ meshes
|
||||
|
||||
/**
|
||||
* Mesh materials → texture pages/entries; triangles → builder quads {points, texture, tint,
|
||||
* double-sided}. The engine shows each triangle as one half of a texture rectangle, so every
|
||||
* triangle gets the rectangle around its UVs on a (possibly flipped) copy of its texture with
|
||||
* the corner order that fits best; UVs that tile beyond 0..1 are clamped.
|
||||
*/
|
||||
private static final class MeshTextures {
|
||||
private final Scene s;
|
||||
private final List<TextureEntry> genTex;
|
||||
private final TextureAtlas atlas;
|
||||
private final Map<String, TextureAtlas.Slot> slots = new HashMap<>();
|
||||
private final Map<String, Integer> entries = new HashMap<>();
|
||||
private final int texBase;
|
||||
|
||||
MeshTextures(Scene s, List<TexturePage> genPages, List<TextureEntry> genTex) {
|
||||
this.s = s;
|
||||
this.genTex = genTex;
|
||||
int[] n = {0};
|
||||
atlas = new TextureAtlas(s.pages().size(), () -> {
|
||||
TexturePage p = new TexturePage("gen:page:" + n[0]++);
|
||||
genPages.add(p);
|
||||
return p;
|
||||
});
|
||||
texBase = s.textures().size();
|
||||
}
|
||||
|
||||
List<MeshQuad> triangles(Brush b, List<String> warn) {
|
||||
MeshAsset a = (MeshAsset) b.ref("asset");
|
||||
if (a == null) {
|
||||
warn.add(b.label() + ": no mesh asset");
|
||||
return List.of();
|
||||
}
|
||||
MeshData m = a.mesh();
|
||||
if (m == null) {
|
||||
try {
|
||||
m = MeshImport.load(s, a);
|
||||
} catch (java.io.IOException | RuntimeException e) {
|
||||
warn.add(b.label() + ": " + a.file() + " cannot be read: " + e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
double sc = b.d("scale");
|
||||
double[] st = b.get("stretch") instanceof double[] x && x.length == 3 ? x : new double[] {1, 1, 1};
|
||||
boolean mirror = st[0] * st[1] * st[2] < 0;
|
||||
double ang = Math.toRadians(b.d("rotY"));
|
||||
double ca = Math.cos(ang);
|
||||
double sa = Math.sin(ang);
|
||||
double ox = b.d("x");
|
||||
double oy = b.d("y");
|
||||
double oz = b.d("z");
|
||||
int size = b.i("texSize");
|
||||
int steps = Math.max(1, b.i("uvSteps"));
|
||||
double[] tint = b.a("tint");
|
||||
List<MeshQuad> out = new ArrayList<>();
|
||||
double[] pos = m.positions();
|
||||
for (int t = 0; t < m.triangleCount(); t++) {
|
||||
double[][] p = new double[3][];
|
||||
for (int k = 0; k < 3; k++) {
|
||||
int v = m.triangles()[3 * t + k];
|
||||
double x = pos[3 * v] * sc * st[0];
|
||||
double y = pos[3 * v + 1] * sc * st[1];
|
||||
double z = pos[3 * v + 2] * sc * st[2];
|
||||
p[k] = new double[] {ox + x * ca + z * sa, oy + y, oz - x * sa + z * ca};
|
||||
}
|
||||
MeshData.Material mat = m.materialList().get(m.materials()[t]);
|
||||
double[] uv = Arrays.copyOfRange(m.uvs(), 6 * t, 6 * t + 6);
|
||||
if (mirror) {
|
||||
double[] q = p[1];
|
||||
p[1] = p[2];
|
||||
p[2] = q;
|
||||
double u1 = uv[2], v1 = uv[3];
|
||||
uv[2] = uv[4];
|
||||
uv[3] = uv[5];
|
||||
uv[4] = u1;
|
||||
uv[5] = v1;
|
||||
}
|
||||
double[] tn = tint.clone();
|
||||
if (m.colors() != null) {
|
||||
double[] c = new double[3];
|
||||
for (int k = 0; k < 3; k++) {
|
||||
int col = m.colors()[m.triangles()[3 * t + k]];
|
||||
c[0] += (col >> 16 & 0xff) / 255.0 / 3;
|
||||
c[1] += (col >> 8 & 0xff) / 255.0 / 3;
|
||||
c[2] += (col & 0xff) / 255.0 / 3;
|
||||
}
|
||||
for (int k = 0; k < 3; k++) {
|
||||
tn[k] *= c[k];
|
||||
}
|
||||
}
|
||||
String tex;
|
||||
if (mat.image() == null || Double.isNaN(uv[0])) {
|
||||
tex = LevelBuilder.rgb(mat.rgb() >> 16 & 0xff, mat.rgb() >> 8 & 0xff, mat.rgb() & 0xff);
|
||||
} else {
|
||||
int[] best = fit(uv);
|
||||
int r = best[0];
|
||||
int variant = best[1];
|
||||
p = new double[][] {p[r], p[(r + 1) % 3], p[(r + 2) % 3]};
|
||||
double[] u2 = new double[6];
|
||||
for (int k = 0; k < 3; k++) {
|
||||
int src = (r + k) % 3;
|
||||
u2[2 * k] = flip(uv[2 * src], (variant & 1) != 0);
|
||||
u2[2 * k + 1] = flip(uv[2 * src + 1], (variant & 2) != 0);
|
||||
}
|
||||
tex = "#" + entry(m.materials()[t], a, mat, variant, size, steps, u2);
|
||||
}
|
||||
out.add(new MeshQuad(p, tex, tn, b.b("doubleSided") || mat.doubleSided()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double flip(double v, boolean f) {
|
||||
return f ? 1 - v : v;
|
||||
}
|
||||
|
||||
/** {rotation, flip variant (bit 0 U, bit 1 V)} with the smallest corner error. */
|
||||
static int[] fit(double[] uv) {
|
||||
int[] best = {0, 0};
|
||||
double err = Double.MAX_VALUE;
|
||||
for (int variant = 0; variant < 4; variant++) {
|
||||
for (int r = 0; r < 3; r++) {
|
||||
double[] u = new double[3];
|
||||
double[] v = new double[3];
|
||||
for (int k = 0; k < 3; k++) {
|
||||
int src = (r + k) % 3;
|
||||
u[k] = flip(uv[2 * src], (variant & 1) != 0);
|
||||
v[k] = flip(uv[2 * src + 1], (variant & 2) != 0);
|
||||
}
|
||||
double umin = Math.min(u[0], Math.min(u[1], u[2]));
|
||||
double umax = Math.max(u[0], Math.max(u[1], u[2]));
|
||||
double vmin = Math.min(v[0], Math.min(v[1], v[2]));
|
||||
double vmax = Math.max(v[0], Math.max(v[1], v[2]));
|
||||
// pattern of LevelBuilder triangles (0x820): bottom-left, bottom-right, top-right
|
||||
double e = Math.abs(u[0] - umin)
|
||||
+ Math.abs(v[0] - vmax)
|
||||
+ Math.abs(u[1] - umax)
|
||||
+ Math.abs(v[1] - vmax)
|
||||
+ Math.abs(u[2] - umax)
|
||||
+ Math.abs(v[2] - vmin);
|
||||
if (e < err - 1e-9) {
|
||||
err = e;
|
||||
best = new int[] {r, variant};
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private int entry(
|
||||
int matIndex, MeshAsset a, MeshData.Material mat, int variant, int size, int steps, double[] uv) {
|
||||
String sk = a.id() + "/" + matIndex + "/" + variant + "/" + size;
|
||||
TextureAtlas.Slot slot = slots.get(sk);
|
||||
if (slot == null) {
|
||||
BufferedImage img = new BufferedImage(mat.width(), mat.height(), BufferedImage.TYPE_INT_ARGB);
|
||||
for (int y = 0; y < mat.height(); y++) {
|
||||
for (int x = 0; x < mat.width(); x++) {
|
||||
int sx = (variant & 1) != 0 ? mat.width() - 1 - x : x;
|
||||
int sy = (variant & 2) != 0 ? mat.height() - 1 - y : y;
|
||||
img.setRGB(x, y, mat.image()[sy * mat.width() + sx]);
|
||||
}
|
||||
}
|
||||
slot = atlas.add(img, size);
|
||||
slots.put(sk, slot);
|
||||
}
|
||||
double umin = Double.MAX_VALUE, umax = -Double.MAX_VALUE, vmin = Double.MAX_VALUE, vmax = -Double.MAX_VALUE;
|
||||
for (int k = 0; k < 3; k++) {
|
||||
umin = Math.min(umin, uv[2 * k]);
|
||||
umax = Math.max(umax, uv[2 * k]);
|
||||
vmin = Math.min(vmin, uv[2 * k + 1]);
|
||||
vmax = Math.max(vmax, uv[2 * k + 1]);
|
||||
}
|
||||
double fu = Math.floor(umin);
|
||||
double fv = Math.floor(vmin);
|
||||
umin = clamp01(umin - fu);
|
||||
umax = clamp01(umax - fu);
|
||||
vmin = clamp01(vmin - fv);
|
||||
vmax = clamp01(vmax - fv);
|
||||
int q = Math.max(1, slot.size() / steps);
|
||||
int u0 = (int) Math.floor(umin * slot.size() / q) * q;
|
||||
int u1 = (int) Math.ceil(umax * slot.size() / q) * q - 1;
|
||||
int v0 = (int) Math.floor(vmin * slot.size() / q) * q;
|
||||
int v1 = (int) Math.ceil(vmax * slot.size() / q) * q - 1;
|
||||
u1 = Math.max(u0, Math.min(slot.size() - 1, u1));
|
||||
v1 = Math.max(v0, Math.min(slot.size() - 1, v1));
|
||||
String ek = slot.page() + "/" + (slot.x() + u0) + "/" + (slot.x() + u1) + "/" + (slot.y() + v0) + "/"
|
||||
+ (slot.y() + v1);
|
||||
Integer idx = entries.get(ek);
|
||||
if (idx == null) {
|
||||
idx = texBase + genTex.size();
|
||||
TextureEntry te = new TextureEntry("gen:tex:" + idx);
|
||||
te.setRaw("flags", MESH_TEXTURE_FLAGS);
|
||||
te.setRaw("page", slot.page());
|
||||
te.setRaw("page2", -1);
|
||||
te.setRaw("u0", slot.x() + u0);
|
||||
te.setRaw("u1", slot.x() + u1);
|
||||
te.setRaw("v0", slot.y() + v0);
|
||||
te.setRaw("v1", slot.y() + v1);
|
||||
genTex.add(te);
|
||||
entries.put(ek, idx);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
private static double clamp01(double v) {
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureAnim;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureEntry;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.format.ByteSink;
|
||||
import com.eliasmueller.llamamod.format.Face;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Model;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Trak;
|
||||
import com.eliasmueller.llamamod.format.Vertex;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Serialises scene parts into chunk data (inverse of {@link WadImporter}). */
|
||||
final class ChunkWriter {
|
||||
private ChunkWriter() {}
|
||||
|
||||
/** Tile model to the format layer (header kept while the vertices are unchanged). */
|
||||
static Model toModel(TileModel tm) {
|
||||
Model m = new Model();
|
||||
double[] v = tm.vertices();
|
||||
for (int i = 0; i < v.length; i += TileModel.VERTEX_STRIDE) {
|
||||
m.verts.add(new Vertex(v[i], v[i + 1], v[i + 2], v[i + 3], v[i + 4], v[i + 5]));
|
||||
}
|
||||
int[] f = tm.faces();
|
||||
double[] p = tm.planes();
|
||||
for (int i = 0, k = 0; i < f.length; i += TileModel.FACE_STRIDE, k += TileModel.PLANE_STRIDE) {
|
||||
m.faces.add(new Face(
|
||||
f[i], f[i + 1], f[i + 2], f[i + 3], f[i + 4], f[i + 5], p[k], p[k + 1], p[k + 2], p[k + 3]));
|
||||
}
|
||||
for (int g = 0; g < 3; g++) {
|
||||
byte[] c = tm.collision(g);
|
||||
for (int o = 0; o + TileModel.COLLISION_SIZE <= c.length; o += TileModel.COLLISION_SIZE) {
|
||||
m.coll.get(g).add(Arrays.copyOfRange(c, o, o + TileModel.COLLISION_SIZE));
|
||||
}
|
||||
}
|
||||
if (tm.header() != null) {
|
||||
m.header = Arrays.copyOf(tm.header(), Trak.TRAK_HEADER);
|
||||
m.origVertCount = m.verts.size();
|
||||
} else {
|
||||
m.dirty = true;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
static byte[] trak(List<TileModel> models) {
|
||||
List<Model> l = new ArrayList<>(models.size());
|
||||
for (TileModel tm : models) {
|
||||
l.add(toModel(tm));
|
||||
}
|
||||
return Trak.build(l);
|
||||
}
|
||||
|
||||
static Map<Object, Integer> indexMap(List<?> l) {
|
||||
Map<Object, Integer> m = new IdentityHashMap<>(l.size() * 2);
|
||||
for (int i = 0; i < l.size(); i++) {
|
||||
m.put(l.get(i), i);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Effective strat pool size written to MAP (enough slots for all placed strats). */
|
||||
static int poolExtra(Scene s) {
|
||||
LevelSettings st = s.settings();
|
||||
return Math.max(st.poolExtra(), s.strats().size() + st.poolReserve());
|
||||
}
|
||||
|
||||
private static final int[] DUMMY_NODE = new int[10];
|
||||
|
||||
/**
|
||||
* MAP chunk. models/tiles: the scene's lists plus generated geometry (brushes); every tile's
|
||||
* model must be in models.
|
||||
*/
|
||||
static byte[] map(Scene s, List<TileModel> models, List<Tile> tiles) {
|
||||
LevelSettings st = s.settings();
|
||||
int wf = st.wfpc();
|
||||
Map<Object, Integer> mi = indexMap(models);
|
||||
Map<Object, Integer> ni = indexMap(s.pathNodes());
|
||||
ByteSink o = new ByteSink(1 << 20);
|
||||
o.i32(tiles.size()).i32(st.width()).i32(st.height());
|
||||
boolean alts = (wf & MapChunk.WF_ALTS) != 0;
|
||||
int lastSwap = -1;
|
||||
if (alts) {
|
||||
for (int i = 0; i < tiles.size(); i++) {
|
||||
if (tiles.get(i).hasSwap()) {
|
||||
lastSwap = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < tiles.size(); i++) {
|
||||
Tile t = tiles.get(i);
|
||||
int a = t.alt();
|
||||
if (alts && t.hasSwap() && a == 0) {
|
||||
a = 1;
|
||||
} else if (alts && !t.hasSwap() && a != 0 && i < lastSwap) {
|
||||
a = 0;
|
||||
}
|
||||
o.f32(t.x())
|
||||
.f32(t.y())
|
||||
.f32(t.z())
|
||||
.i32(t.rot())
|
||||
.i32(index(mi, t.model(), t))
|
||||
.i32(a);
|
||||
}
|
||||
List<Integer> pool = new ArrayList<>();
|
||||
int[] pidx = paramIndices(s, pool);
|
||||
o.i32(pool.size());
|
||||
for (int v : pool) {
|
||||
o.i32(v);
|
||||
}
|
||||
o.i32(s.zones().size());
|
||||
for (EnvZone z : s.zones()) {
|
||||
for (int i = 0; i < EnvZone.FIELDS.length; i++) {
|
||||
if (i == 14 || i == 21 || i == 22) {
|
||||
o.u16(z.field(i));
|
||||
} else {
|
||||
o.i32(z.field(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean dummy = s.pathNodes().isEmpty() && st.isNewLevel();
|
||||
o.i32(dummy ? 1 : s.pathNodes().size());
|
||||
if (dummy) {
|
||||
node(o, DUMMY_NODE);
|
||||
}
|
||||
for (PathNode n : s.pathNodes()) {
|
||||
int next = n.next() != null ? index(ni, n.next(), n) : n.nextRaw();
|
||||
node(o, new int[] {next, n.unk1(), n.rx(), n.ry(), n.rz(), n.x(), n.y(), n.z(), n.unk2(), n.unk3()});
|
||||
}
|
||||
o.i32s(s.regions().table());
|
||||
o.i32s(s.regions().grid());
|
||||
for (Tile t : tiles) {
|
||||
o.i32s(t.effectiveFixed());
|
||||
}
|
||||
for (Tile t : tiles) {
|
||||
o.i32(index(mi, t.model(), t));
|
||||
}
|
||||
if (alts) {
|
||||
int n = 0;
|
||||
for (Tile t : tiles) {
|
||||
n += t.hasSwap() ? 1 : 0;
|
||||
}
|
||||
o.i32(n);
|
||||
for (Tile t : tiles) {
|
||||
if (t.hasSwap()) {
|
||||
int[] c = t.swapCell();
|
||||
o.i32(c[0]).i32(c[1]).i32(c[2]);
|
||||
o.i32(t.swapModel() != null ? index(mi, t.swapModel(), t) : index(mi, t.model(), t));
|
||||
o.i32(index(mi, t.swapAltModel(), t));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Tile t : tiles) {
|
||||
o.i32s(colors(t.colors(), t.model().vertexCount()));
|
||||
if (alts && t.hasSwap()) {
|
||||
int[] ac = t.altColors();
|
||||
o.i32s(colors(ac == null ? new int[0] : ac, t.swapAltModel().vertexCount()));
|
||||
}
|
||||
}
|
||||
o.i32(s.strats().size()).i32(poolExtra(s));
|
||||
for (int k = 0; k < pidx.length; k++) {
|
||||
Strat x = s.strats().get(k);
|
||||
int wp = x.waypoint() != null ? index(ni, x.waypoint(), x) : x.waypointRaw();
|
||||
o.u16(x.rx()).u16(x.ry()).u16(x.rz());
|
||||
o.i32(x.x()).i32(x.y()).i32(x.z());
|
||||
o.i32(x.code()).i32(x.params().length).i32(pidx[k]);
|
||||
o.i32(x.nVars()).i32(x.slots()).i32(x.typeFlags()).i32(x.extra());
|
||||
o.i32(wp).i32(x.zero()).u16(x.flags()).u16(x.last());
|
||||
}
|
||||
o.bytes(st.mapTail());
|
||||
return o.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills pool with the parameter pool and returns each strat's pool index: unchanged parameters
|
||||
* keep their original index, changed ones are appended.
|
||||
*/
|
||||
private static int[] paramIndices(Scene s, List<Integer> pool) {
|
||||
int[] pool0 = s.settings().paramPool();
|
||||
for (int v : pool0) {
|
||||
pool.add(v);
|
||||
}
|
||||
int[] pidx = new int[s.strats().size()];
|
||||
for (int k = 0; k < pidx.length; k++) {
|
||||
Strat x = s.strats().get(k);
|
||||
int[] p = x.params();
|
||||
Integer pi = x.paramIndex();
|
||||
if (p.length == 0) {
|
||||
pidx[k] = pi != null ? pi : -1;
|
||||
} else if (pi != null
|
||||
&& pi >= 0
|
||||
&& pi + p.length <= pool0.length
|
||||
&& Arrays.equals(p, Arrays.copyOfRange(pool0, pi, pi + p.length))) {
|
||||
pidx[k] = pi;
|
||||
} else {
|
||||
pidx[k] = pool.size();
|
||||
for (int v : p) {
|
||||
pool.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pidx;
|
||||
}
|
||||
|
||||
private static void node(ByteSink o, int[] v) {
|
||||
o.i32(v[0]).i32(v[1]).u16(v[2]).u16(v[3]).u16(v[4]);
|
||||
o.i32(v[5]).i32(v[6]).i32(v[7]).i32(v[8]).i32(v[9]);
|
||||
}
|
||||
|
||||
private static int index(Map<Object, Integer> m, Object target, Object user) {
|
||||
Integer i = m.get(target);
|
||||
if (i == null) {
|
||||
throw new IllegalStateException(user + " references " + target + ", which is not part of the level");
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour block of one tile: frames × nv words. A length that does not fit the model (vertices
|
||||
* edited) is repaired: the first frame is resized (new vertices grey) and the frame count
|
||||
* byte kept consistent.
|
||||
*/
|
||||
static int[] colors(int[] c, int nv) {
|
||||
if (nv == 0) {
|
||||
return new int[0];
|
||||
}
|
||||
int frames = 1;
|
||||
for (int i = 0; i < Math.min(nv, c.length); i++) {
|
||||
frames = Math.max(frames, c[i] >>> 24);
|
||||
}
|
||||
if (c.length == frames * nv) {
|
||||
return c;
|
||||
}
|
||||
int[] out = new int[nv];
|
||||
for (int i = 0; i < nv; i++) {
|
||||
out[i] = i < c.length ? c[i] & 0x00ffffff : Tile.GREY;
|
||||
}
|
||||
out[0] |= 0x01000000;
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] text(Scene s, List<TexturePage> pages, List<TextureEntry> entries) {
|
||||
ByteSink o = new ByteSink(1 << 20);
|
||||
o.bytes(s.settings().textPalettes());
|
||||
o.i32(pages.size());
|
||||
for (TexturePage p : pages) {
|
||||
o.bytes(encodePage(p));
|
||||
}
|
||||
o.i32(entries.size());
|
||||
for (TextureEntry e : entries) {
|
||||
o.u16(e.flags())
|
||||
.u8(e.page())
|
||||
.u8(e.page2())
|
||||
.u8(e.u0())
|
||||
.u8(e.u1())
|
||||
.u8(e.v0())
|
||||
.u8(e.v1());
|
||||
}
|
||||
o.i32(s.textureAnims().size());
|
||||
for (TextureAnim a : s.textureAnims()) {
|
||||
o.u16(a.texture()).u8(a.type()).bytes(a.data());
|
||||
}
|
||||
o.bytes(s.settings().textTail());
|
||||
return o.toByteArray();
|
||||
}
|
||||
|
||||
/** Page record {flags, w, h, data} (cached original bytes while unchanged). */
|
||||
static byte[] encodePage(TexturePage p) {
|
||||
if (p.encoded() != null) {
|
||||
return p.encoded();
|
||||
}
|
||||
ByteSink o = new ByteSink(p.pixels().length * 2 + 16);
|
||||
int f = p.flags();
|
||||
o.i32(f).i32(p.width()).i32(p.height());
|
||||
if (f == 0) {
|
||||
for (int v : p.pixels()) {
|
||||
o.u8(v);
|
||||
}
|
||||
} else if ((f & 0x80) != 0) {
|
||||
byte[] b = TextChunk.rleEncode(p.pixels());
|
||||
o.i32(b.length).bytes(b);
|
||||
} else {
|
||||
for (int v : p.pixels()) {
|
||||
o.u16(v);
|
||||
}
|
||||
}
|
||||
byte[] out = o.toByteArray();
|
||||
p.setEncodedCache(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] lights(Scene s) {
|
||||
ByteSink o = new ByteSink(1024);
|
||||
o.i32(s.lights().size());
|
||||
for (Light l : s.lights()) {
|
||||
o.u8(l.type()).u8(l.r()).u8(l.g()).u8(l.b());
|
||||
if (l.type() == Light.DIRECTIONAL) {
|
||||
o.f32(l.x()).f32(l.y()).f32(l.z());
|
||||
} else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
|
||||
o.f32(l.x()).f32(l.y()).f32(l.z()).f32(l.a()).f32(l.rangeB()).u8(l.c());
|
||||
}
|
||||
}
|
||||
o.bytes(s.settings().lghtTail());
|
||||
return o.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/** PNG ↔ texture page pixels. ABGR1555 → RGBA → ABGR1555 is lossless (alpha 0 / 128 / 255). */
|
||||
public final class Images {
|
||||
private Images() {}
|
||||
|
||||
public static BufferedImage pageImage(TexturePage p) {
|
||||
BufferedImage img = new BufferedImage(p.width(), p.height(), BufferedImage.TYPE_INT_ARGB);
|
||||
img.setRGB(0, 0, p.width(), p.height(), p.argb(), 0, p.width());
|
||||
return img;
|
||||
}
|
||||
|
||||
public static byte[] pagePng(TexturePage p) {
|
||||
return png(pageImage(p));
|
||||
}
|
||||
|
||||
public static byte[] png(BufferedImage img) {
|
||||
try {
|
||||
ByteArrayOutputStream o = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", o);
|
||||
return o.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static BufferedImage read(byte[] data) throws IOException {
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(data));
|
||||
if (img == null) {
|
||||
throw new IOException("not a readable image (PNG or JPEG expected)");
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
/** ABGR1555 pixels of an image (scaled to w×h if the size differs). */
|
||||
public static int[] pagePixels(byte[] png, int w, int h) {
|
||||
try {
|
||||
return toAbgr1555(read(png), w, h);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static int[] toAbgr1555(BufferedImage img, int w, int h) {
|
||||
BufferedImage src = img.getWidth() == w && img.getHeight() == h ? img : scale(img, w, h);
|
||||
boolean alpha = src.getColorModel().hasAlpha();
|
||||
int[] out = new int[w * h];
|
||||
for (int y = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++) {
|
||||
int c = src.getRGB(x, y);
|
||||
out[y * w + x] = TextChunk.rgbaToAbgr1555(c >> 16 & 0xff, c >> 8 & 0xff, c & 0xff, c >>> 24, alpha);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Area-averaged scaling (premultiplied by alpha so transparent pixels do not bleed). */
|
||||
public static BufferedImage scale(BufferedImage img, int w, int h) {
|
||||
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
|
||||
int sw = img.getWidth();
|
||||
int sh = img.getHeight();
|
||||
for (int y = 0; y < h; y++) {
|
||||
int y0 = y * sh / h;
|
||||
int y1 = Math.max(y0 + 1, (y + 1) * sh / h);
|
||||
for (int x = 0; x < w; x++) {
|
||||
int x0 = x * sw / w;
|
||||
int x1 = Math.max(x0 + 1, (x + 1) * sw / w);
|
||||
long r = 0, g = 0, b = 0, a = 0;
|
||||
int n = 0;
|
||||
for (int yy = y0; yy < y1; yy++) {
|
||||
for (int xx = x0; xx < x1; xx++) {
|
||||
int c = img.getRGB(xx, yy);
|
||||
int ca = c >>> 24;
|
||||
a += ca;
|
||||
r += (c >> 16 & 0xff) * ca;
|
||||
g += (c >> 8 & 0xff) * ca;
|
||||
b += (c & 0xff) * ca;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
int oa = (int) (a / n);
|
||||
int or = a == 0 ? 0 : (int) (r / a);
|
||||
int og = a == 0 ? 0 : (int) (g / a);
|
||||
int ob = a == 0 ? 0 : (int) (b / a);
|
||||
out.setRGB(x, y, oa << 24 | or << 16 | og << 8 | ob);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.MeshAsset;
|
||||
import com.eliasmueller.llamamod.editor.model.MeshData;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Imports 3D models as {@link MeshAsset}s: Wavefront OBJ (+ MTL, diffuse colour and map_Kd
|
||||
* texture, optional vertex colours "v x y z r g b") and glTF 2.0 (.gltf with external or embedded
|
||||
* buffers/images, .glb; node transforms, base colour factor/texture, COLOR_0, doubleSided).
|
||||
* Files are copied into the project assets; the mesh is parsed from there, so a project is
|
||||
* self-contained. Axes: files are y-up right-handed (or z-up, {@code MeshAsset.upAxis}); 1 file
|
||||
* unit = {@code unitScale} cells. In game space z is mirrored (the result is not mirrored).
|
||||
*/
|
||||
public final class MeshImport {
|
||||
/** Diffuse colour of materials without one. */
|
||||
private static final int GREY = 0xc0c0c0;
|
||||
|
||||
private static final MeshData.Material DEFAULT_MATERIAL = new MeshData.Material("default", GREY, null, 0, 0, false);
|
||||
|
||||
private MeshImport() {}
|
||||
|
||||
/** Copies the file (and the files it references) into the scene assets; returns a detached asset node. */
|
||||
public static MeshAsset importFile(Scene s, Path file) throws IOException {
|
||||
String fn = file.getFileName().toString();
|
||||
String stem = fn.contains(".") ? fn.substring(0, fn.lastIndexOf('.')) : fn;
|
||||
String dir = "assets/" + slug(stem);
|
||||
int n = 2;
|
||||
while (hasDir(s, dir)) {
|
||||
dir = "assets/" + slug(stem) + "-" + n++;
|
||||
}
|
||||
Path parent = file.toAbsolutePath().normalize().getParent();
|
||||
Map<String, byte[]> files = new LinkedHashMap<>();
|
||||
byte[] main = Files.readAllBytes(file);
|
||||
files.put(fn, main);
|
||||
for (String dep : dependencies(fn, main, rel -> read(parent, rel))) {
|
||||
byte[] d = read(parent, dep);
|
||||
if (d != null) {
|
||||
files.put(dep, d);
|
||||
}
|
||||
}
|
||||
MeshAsset a = (MeshAsset) s.create(NodeKind.MESH_ASSET);
|
||||
a.setRaw("name", stem);
|
||||
String d0 = dir;
|
||||
files.forEach((k, v) -> s.assets().put(d0 + "/" + k, v));
|
||||
a.setRaw("file", dir + "/" + fn);
|
||||
a.setMesh(parse(fn, main, rel -> s.assets().get(d0 + "/" + rel), a.upAxis(), a.unitScale()));
|
||||
return a;
|
||||
}
|
||||
|
||||
private static boolean hasDir(Scene s, String dir) {
|
||||
return s.assets().keySet().stream().anyMatch(k -> k.startsWith(dir + "/"));
|
||||
}
|
||||
|
||||
private static byte[] read(Path dir, String rel) {
|
||||
try {
|
||||
Path p = dir.resolve(rel.replace('\\', '/')).normalize();
|
||||
return p.startsWith(dir) && Files.isRegularFile(p) ? Files.readAllBytes(p) : null;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String slug(String s) {
|
||||
String r = s.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "-").replaceAll("^-+|-+$", "");
|
||||
return r.isEmpty() ? "mesh" : r;
|
||||
}
|
||||
|
||||
/** Parses the asset from the scene's assets (and caches it in the node). */
|
||||
public static MeshData load(Scene s, MeshAsset a) throws IOException {
|
||||
String f = a.file();
|
||||
byte[] main = s.assets().get(f);
|
||||
if (main == null) {
|
||||
throw new IOException("asset " + f + " missing in the project");
|
||||
}
|
||||
String dir = f.contains("/") ? f.substring(0, f.lastIndexOf('/') + 1) : "";
|
||||
MeshData m = parse(
|
||||
f.substring(dir.length()),
|
||||
main,
|
||||
rel -> s.assets().get(dir + rel.replace('\\', '/')),
|
||||
a.upAxis(),
|
||||
a.unitScale());
|
||||
a.setMesh(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Files referenced by an OBJ (MTL, textures) or glTF (buffers, images). */
|
||||
static List<String> dependencies(String name, byte[] main, Function<String, byte[]> files) {
|
||||
List<String> out = new ArrayList<>();
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (lower.endsWith(".obj")) {
|
||||
for (String line : new String(main, StandardCharsets.UTF_8).split("\r?\n")) {
|
||||
String t = line.trim();
|
||||
if (t.startsWith("mtllib ")) {
|
||||
String mtl = t.substring(7).trim();
|
||||
out.add(mtl);
|
||||
byte[] md = files.apply(mtl);
|
||||
if (md != null) {
|
||||
for (String ml : new String(md, StandardCharsets.UTF_8).split("\r?\n")) {
|
||||
String m = ml.trim();
|
||||
if (m.toLowerCase(Locale.ROOT).startsWith("map_kd ")) {
|
||||
out.add(lastToken(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (lower.endsWith(".gltf")) {
|
||||
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(main, StandardCharsets.UTF_8));
|
||||
for (String k : List.of("buffers", "images")) {
|
||||
if (j.get(k) instanceof List<?> l) {
|
||||
for (Object o : l) {
|
||||
if (((Map<?, ?>) o).get("uri") instanceof String u && !u.startsWith("data:")) {
|
||||
out.add(URLDecoder.decode(u, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String lastToken(String s) {
|
||||
String[] p = s.trim().split("\\s+");
|
||||
return p[p.length - 1];
|
||||
}
|
||||
|
||||
public static MeshData parse(String name, byte[] main, Function<String, byte[]> files, String up, double scale)
|
||||
throws IOException {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
Builder b = new Builder(up, scale);
|
||||
if (lower.endsWith(".obj")) {
|
||||
obj(main, files, b);
|
||||
} else if (lower.endsWith(".glb")) {
|
||||
glb(main, files, b);
|
||||
} else if (lower.endsWith(".gltf")) {
|
||||
gltf((Map<?, ?>) Json.parse(new String(main, StandardCharsets.UTF_8)), null, files, b);
|
||||
} else {
|
||||
throw new IOException("unsupported model format (OBJ, glTF or GLB expected): " + name);
|
||||
}
|
||||
if (b.tris.isEmpty()) {
|
||||
throw new IOException(name + " contains no triangles");
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
/** Collects triangles in game space. */
|
||||
private static final class Builder {
|
||||
final List<double[]> pos = new ArrayList<>();
|
||||
final List<Integer> cols = new ArrayList<>();
|
||||
boolean hasCols;
|
||||
final List<int[]> tris = new ArrayList<>();
|
||||
final List<double[]> uvs = new ArrayList<>();
|
||||
final List<Integer> mats = new ArrayList<>();
|
||||
final List<MeshData.Material> materials = new ArrayList<>();
|
||||
final String up;
|
||||
final double scale;
|
||||
|
||||
Builder(String up, double scale) {
|
||||
this.up = up;
|
||||
this.scale = scale;
|
||||
}
|
||||
|
||||
/** File coordinates (right-handed) → game space. */
|
||||
int vertex(double x, double y, double z, Integer rgb) {
|
||||
double gx = x;
|
||||
double gy = "z".equals(up) ? z : y;
|
||||
double gz = "z".equals(up) ? y : -z;
|
||||
pos.add(new double[] {gx * scale, gy * scale, gz * scale});
|
||||
cols.add(rgb == null ? 0xffffff : rgb);
|
||||
hasCols |= rgb != null;
|
||||
return pos.size() - 1;
|
||||
}
|
||||
|
||||
void tri(int a, int b, int c, double[] uv, int mat) {
|
||||
tris.add(new int[] {a, b, c});
|
||||
uvs.add(uv);
|
||||
mats.add(mat);
|
||||
}
|
||||
|
||||
int material(MeshData.Material m) {
|
||||
materials.add(m);
|
||||
return materials.size() - 1;
|
||||
}
|
||||
|
||||
MeshData build() {
|
||||
if (materials.isEmpty()) {
|
||||
materials.add(DEFAULT_MATERIAL);
|
||||
}
|
||||
double[] p = new double[pos.size() * 3];
|
||||
for (int i = 0; i < pos.size(); i++) {
|
||||
System.arraycopy(pos.get(i), 0, p, 3 * i, 3);
|
||||
}
|
||||
int[] t = new int[tris.size() * 3];
|
||||
double[] u = new double[tris.size() * 6];
|
||||
int[] m = new int[tris.size()];
|
||||
for (int i = 0; i < tris.size(); i++) {
|
||||
System.arraycopy(tris.get(i), 0, t, 3 * i, 3);
|
||||
System.arraycopy(uvs.get(i), 0, u, 6 * i, 6);
|
||||
m[i] = Math.min(mats.get(i), materials.size() - 1);
|
||||
}
|
||||
int[] c = null;
|
||||
if (hasCols) {
|
||||
c = cols.stream().mapToInt(Integer::intValue).toArray();
|
||||
}
|
||||
return new MeshData(p, t, u, m, c, List.copyOf(materials));
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] argb(BufferedImage img) {
|
||||
int w = img.getWidth();
|
||||
int h = img.getHeight();
|
||||
return img.getRGB(0, 0, w, h, null, 0, w);
|
||||
}
|
||||
|
||||
private static final double[] NO_UV = {Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN};
|
||||
|
||||
// ------------------------------------------------------------ OBJ
|
||||
|
||||
private static void obj(byte[] data, Function<String, byte[]> files, Builder b) throws IOException {
|
||||
List<double[]> v = new ArrayList<>();
|
||||
List<Integer> vc = new ArrayList<>();
|
||||
List<double[]> vt = new ArrayList<>();
|
||||
Map<String, MeshData.Material> lib = new HashMap<>();
|
||||
Map<String, Integer> used = new HashMap<>();
|
||||
int cur = -1;
|
||||
Map<Integer, Integer> vmap = new HashMap<>();
|
||||
for (String raw : new String(data, StandardCharsets.UTF_8).split("\r?\n")) {
|
||||
String line = raw.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
String[] p = line.split("\\s+");
|
||||
switch (p[0]) {
|
||||
case "v" -> {
|
||||
v.add(new double[] {d(p[1]), d(p[2]), d(p[3])});
|
||||
vc.add(p.length >= 7 ? rgb(d(p[4]), d(p[5]), d(p[6])) : null);
|
||||
}
|
||||
case "vt" -> vt.add(new double[] {d(p[1]), p.length > 2 ? d(p[2]) : 0});
|
||||
case "mtllib" -> lib.putAll(mtl(files.apply(line.substring(6).trim()), files));
|
||||
case "usemtl" -> {
|
||||
String n = line.substring(6).trim();
|
||||
Integer i = used.get(n);
|
||||
if (i == null) {
|
||||
MeshData.Material m = lib.getOrDefault(n, new MeshData.Material(n, GREY, null, 0, 0, false));
|
||||
i = b.material(m);
|
||||
used.put(n, i);
|
||||
}
|
||||
cur = i;
|
||||
}
|
||||
case "f" -> {
|
||||
int n = p.length - 1;
|
||||
int[] vi = new int[n];
|
||||
double[][] uv = new double[n][];
|
||||
for (int k = 0; k < n; k++) {
|
||||
String[] q = p[k + 1].split("/");
|
||||
int a = idx(q[0], v.size());
|
||||
vi[k] = vmap.computeIfAbsent(a, x -> {
|
||||
double[] c = v.get(x);
|
||||
return b.vertex(c[0], c[1], c[2], vc.get(x));
|
||||
});
|
||||
uv[k] = q.length > 1 && !q[1].isEmpty() ? vt.get(idx(q[1], vt.size())) : null;
|
||||
}
|
||||
if (cur < 0) {
|
||||
cur = b.material(DEFAULT_MATERIAL);
|
||||
used.put("", cur);
|
||||
}
|
||||
for (int k = 1; k + 1 < n; k++) {
|
||||
int[] c = {0, k, k + 1};
|
||||
double[] u = NO_UV.clone();
|
||||
if (uv[0] != null && uv[k] != null && uv[k + 1] != null) {
|
||||
for (int j = 0; j < 3; j++) {
|
||||
u[2 * j] = uv[c[j]][0];
|
||||
u[2 * j + 1] = 1 - uv[c[j]][1];
|
||||
}
|
||||
}
|
||||
b.tri(vi[0], vi[k], vi[k + 1], u, cur);
|
||||
}
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int idx(String s, int n) {
|
||||
int i = Integer.parseInt(s);
|
||||
return i > 0 ? i - 1 : n + i;
|
||||
}
|
||||
|
||||
private static double d(String s) {
|
||||
return Double.parseDouble(s);
|
||||
}
|
||||
|
||||
private static int rgb(double r, double g, double b) {
|
||||
return c8(r) << 16 | c8(g) << 8 | c8(b);
|
||||
}
|
||||
|
||||
private static int c8(double v) {
|
||||
return (int) Math.max(0, Math.min(255, Math.round(v * 255)));
|
||||
}
|
||||
|
||||
private static Map<String, MeshData.Material> mtl(byte[] data, Function<String, byte[]> files) throws IOException {
|
||||
Map<String, MeshData.Material> out = new HashMap<>();
|
||||
if (data == null) {
|
||||
return out;
|
||||
}
|
||||
String name = null;
|
||||
int kd = GREY;
|
||||
String map = null;
|
||||
List<String> lines = new ArrayList<>(List.of(new String(data, StandardCharsets.UTF_8).split("\r?\n")));
|
||||
lines.add("newmtl");
|
||||
for (String raw : lines) {
|
||||
String line = raw.trim();
|
||||
String[] p = line.split("\\s+");
|
||||
if (p[0].equals("newmtl")) {
|
||||
if (name != null) {
|
||||
out.put(name, material(name, kd, map == null ? null : files.apply(map), false));
|
||||
}
|
||||
name = line.length() > 7 ? line.substring(7).trim() : null;
|
||||
kd = GREY;
|
||||
map = null;
|
||||
} else if (p[0].equals("Kd") && p.length >= 4) {
|
||||
kd = rgb(d(p[1]), d(p[2]), d(p[3]));
|
||||
} else if (p[0].equalsIgnoreCase("map_Kd")) {
|
||||
map = lastToken(line);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static MeshData.Material material(String name, int rgb, byte[] image, boolean dbl) throws IOException {
|
||||
if (image == null) {
|
||||
return new MeshData.Material(name, rgb, null, 0, 0, dbl);
|
||||
}
|
||||
BufferedImage img = Images.read(image);
|
||||
return new MeshData.Material(name, rgb, argb(img), img.getWidth(), img.getHeight(), dbl);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ glTF
|
||||
|
||||
private static void glb(byte[] d, Function<String, byte[]> files, Builder b) throws IOException {
|
||||
ByteBuffer bb = ByteBuffer.wrap(d).order(ByteOrder.LITTLE_ENDIAN);
|
||||
if (d.length < 20 || bb.getInt(0) != 0x46546c67) {
|
||||
throw new IOException("not a GLB file");
|
||||
}
|
||||
int o = 12;
|
||||
Map<?, ?> json = null;
|
||||
byte[] bin = null;
|
||||
while (o + 8 <= d.length) {
|
||||
int len = bb.getInt(o);
|
||||
int type = bb.getInt(o + 4);
|
||||
byte[] c = Arrays.copyOfRange(d, o + 8, o + 8 + len);
|
||||
if (type == 0x4e4f534a) {
|
||||
json = (Map<?, ?>) Json.parse(new String(c, StandardCharsets.UTF_8));
|
||||
} else if (type == 0x004e4942) {
|
||||
bin = c;
|
||||
}
|
||||
o += 8 + len;
|
||||
}
|
||||
if (json == null) {
|
||||
throw new IOException("GLB without JSON chunk");
|
||||
}
|
||||
gltf(json, bin, files, b);
|
||||
}
|
||||
|
||||
private static List<?> list(Map<?, ?> m, String k) {
|
||||
return m.get(k) instanceof List<?> l ? l : List.of();
|
||||
}
|
||||
|
||||
private static int num(Object o, int def) {
|
||||
return o instanceof Number n ? n.intValue() : def;
|
||||
}
|
||||
|
||||
private static void gltf(Map<?, ?> j, byte[] glbBin, Function<String, byte[]> files, Builder b) throws IOException {
|
||||
List<byte[]> buffers = new ArrayList<>();
|
||||
for (Object o : list(j, "buffers")) {
|
||||
Object uri = ((Map<?, ?>) o).get("uri");
|
||||
if (uri == null) {
|
||||
buffers.add(glbBin);
|
||||
} else {
|
||||
buffers.add(uri((String) uri, files));
|
||||
}
|
||||
}
|
||||
Gltf g = new Gltf(j, buffers, files);
|
||||
Map<Integer, Integer> matIndex = new HashMap<>();
|
||||
List<?> scenes = list(j, "scenes");
|
||||
List<Object> roots = new ArrayList<>();
|
||||
if (!scenes.isEmpty()) {
|
||||
roots.addAll(list((Map<?, ?>) scenes.get(num(j.get("scene"), 0)), "nodes"));
|
||||
} else {
|
||||
for (int i = 0; i < list(j, "nodes").size(); i++) {
|
||||
roots.add((long) i);
|
||||
}
|
||||
}
|
||||
for (Object r : roots) {
|
||||
g.node(num(r, 0), identity(), b, matIndex);
|
||||
}
|
||||
if (roots.isEmpty()) {
|
||||
for (int m = 0; m < list(j, "meshes").size(); m++) {
|
||||
g.mesh(m, identity(), b, matIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] uri(String uri, Function<String, byte[]> files) throws IOException {
|
||||
if (uri.startsWith("data:")) {
|
||||
int c = uri.indexOf(',');
|
||||
return Base64.getDecoder().decode(uri.substring(c + 1));
|
||||
}
|
||||
byte[] d = files.apply(URLDecoder.decode(uri, StandardCharsets.UTF_8));
|
||||
if (d == null) {
|
||||
throw new IOException("referenced file " + uri + " missing");
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
private static double[] identity() {
|
||||
return new double[] {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
|
||||
}
|
||||
|
||||
/** Column-major 4×4 product a·b. */
|
||||
private static double[] mul(double[] a, double[] b) {
|
||||
double[] r = new double[16];
|
||||
for (int c = 0; c < 4; c++) {
|
||||
for (int row = 0; row < 4; row++) {
|
||||
double s = 0;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
s += a[k * 4 + row] * b[c * 4 + k];
|
||||
}
|
||||
r[c * 4 + row] = s;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private static final class Gltf {
|
||||
final Map<?, ?> j;
|
||||
final List<byte[]> buffers;
|
||||
final Function<String, byte[]> files;
|
||||
|
||||
Gltf(Map<?, ?> j, List<byte[]> buffers, Function<String, byte[]> files) {
|
||||
this.j = j;
|
||||
this.buffers = buffers;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
void node(int i, double[] parent, Builder b, Map<Integer, Integer> mats) throws IOException {
|
||||
Map<?, ?> n = (Map<?, ?>) list(j, "nodes").get(i);
|
||||
double[] m = local(n);
|
||||
double[] w = mul(parent, m);
|
||||
if (n.get("mesh") != null) {
|
||||
mesh(num(n.get("mesh"), 0), w, b, mats);
|
||||
}
|
||||
for (Object c : list(n, "children")) {
|
||||
node(num(c, 0), w, b, mats);
|
||||
}
|
||||
}
|
||||
|
||||
static double[] local(Map<?, ?> n) {
|
||||
if (n.get("matrix") instanceof List<?> l) {
|
||||
double[] m = new double[16];
|
||||
for (int k = 0; k < 16; k++) {
|
||||
m[k] = ((Number) l.get(k)).doubleValue();
|
||||
}
|
||||
return m;
|
||||
}
|
||||
double[] t = vec(n.get("translation"), new double[] {0, 0, 0});
|
||||
double[] q = vec(n.get("rotation"), new double[] {0, 0, 0, 1});
|
||||
double[] s = vec(n.get("scale"), new double[] {1, 1, 1});
|
||||
double x = q[0], y = q[1], z = q[2], w = q[3];
|
||||
double[] r = {
|
||||
1 - 2 * (y * y + z * z),
|
||||
2 * (x * y + z * w),
|
||||
2 * (x * z - y * w),
|
||||
0,
|
||||
2 * (x * y - z * w),
|
||||
1 - 2 * (x * x + z * z),
|
||||
2 * (y * z + x * w),
|
||||
0,
|
||||
2 * (x * z + y * w),
|
||||
2 * (y * z - x * w),
|
||||
1 - 2 * (x * x + y * y),
|
||||
0,
|
||||
t[0],
|
||||
t[1],
|
||||
t[2],
|
||||
1
|
||||
};
|
||||
for (int c = 0; c < 3; c++) {
|
||||
for (int row = 0; row < 3; row++) {
|
||||
r[c * 4 + row] *= s[c];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static double[] vec(Object o, double[] def) {
|
||||
if (!(o instanceof List<?> l)) {
|
||||
return def;
|
||||
}
|
||||
double[] v = new double[l.size()];
|
||||
for (int k = 0; k < v.length; k++) {
|
||||
v[k] = ((Number) l.get(k)).doubleValue();
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void mesh(int mi, double[] w, Builder b, Map<Integer, Integer> mats) throws IOException {
|
||||
Map<?, ?> mesh = (Map<?, ?>) list(j, "meshes").get(mi);
|
||||
boolean mirror = det3(w) < 0;
|
||||
for (Object po : list(mesh, "primitives")) {
|
||||
Map<?, ?> p = (Map<?, ?>) po;
|
||||
if (num(p.get("mode"), 4) != 4) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> attr = (Map<?, ?>) p.get("attributes");
|
||||
double[][] pos = accessor(num(attr.get("POSITION"), -1));
|
||||
double[][] uv = attr.get("TEXCOORD_0") != null ? accessor(num(attr.get("TEXCOORD_0"), 0)) : null;
|
||||
double[][] col = attr.get("COLOR_0") != null ? accessor(num(attr.get("COLOR_0"), 0)) : null;
|
||||
int[] idx;
|
||||
if (p.get("indices") != null) {
|
||||
double[][] ix = accessor(num(p.get("indices"), 0));
|
||||
idx = new int[ix.length];
|
||||
for (int k = 0; k < ix.length; k++) {
|
||||
idx[k] = (int) ix[k][0];
|
||||
}
|
||||
} else {
|
||||
idx = new int[pos.length];
|
||||
for (int k = 0; k < idx.length; k++) {
|
||||
idx[k] = k;
|
||||
}
|
||||
}
|
||||
int matI = num(p.get("material"), -1);
|
||||
Integer bm = mats.get(matI);
|
||||
if (bm == null) {
|
||||
bm = b.material(material(matI));
|
||||
mats.put(matI, bm);
|
||||
}
|
||||
int[] vi = new int[pos.length];
|
||||
for (int k = 0; k < pos.length; k++) {
|
||||
double[] q = pos[k];
|
||||
double x = w[0] * q[0] + w[4] * q[1] + w[8] * q[2] + w[12];
|
||||
double y = w[1] * q[0] + w[5] * q[1] + w[9] * q[2] + w[13];
|
||||
double z = w[2] * q[0] + w[6] * q[1] + w[10] * q[2] + w[14];
|
||||
Integer c = col == null ? null : rgb(col[k][0], col[k][1], col[k][2]);
|
||||
vi[k] = b.vertex(x, y, z, c);
|
||||
}
|
||||
for (int k = 0; k + 2 < idx.length; k += 3) {
|
||||
int a = idx[k];
|
||||
int c1 = mirror ? idx[k + 2] : idx[k + 1];
|
||||
int c2 = mirror ? idx[k + 1] : idx[k + 2];
|
||||
double[] u = NO_UV.clone();
|
||||
if (uv != null) {
|
||||
int[] cs = {a, c1, c2};
|
||||
for (int q = 0; q < 3; q++) {
|
||||
u[2 * q] = uv[cs[q]][0];
|
||||
u[2 * q + 1] = uv[cs[q]][1];
|
||||
}
|
||||
}
|
||||
b.tri(vi[a], vi[c1], vi[c2], u, bm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static double det3(double[] m) {
|
||||
return m[0] * (m[5] * m[10] - m[9] * m[6])
|
||||
- m[4] * (m[1] * m[10] - m[9] * m[2])
|
||||
+ m[8] * (m[1] * m[6] - m[5] * m[2]);
|
||||
}
|
||||
|
||||
MeshData.Material material(int i) throws IOException {
|
||||
if (i < 0) {
|
||||
return DEFAULT_MATERIAL;
|
||||
}
|
||||
Map<?, ?> m = (Map<?, ?>) list(j, "materials").get(i);
|
||||
String name = m.get("name") instanceof String s ? s : "material" + i;
|
||||
boolean dbl = Boolean.TRUE.equals(m.get("doubleSided"));
|
||||
Map<?, ?> pbr = m.get("pbrMetallicRoughness") instanceof Map<?, ?> x ? x : Map.of();
|
||||
double[] f = vec(pbr.get("baseColorFactor"), new double[] {1, 1, 1, 1});
|
||||
int rgb = rgb(f[0], f[1], f[2]);
|
||||
if (pbr.get("baseColorTexture") instanceof Map<?, ?> bt) {
|
||||
Map<?, ?> tex = (Map<?, ?>) list(j, "textures").get(num(bt.get("index"), 0));
|
||||
Object src = tex.get("source");
|
||||
if (src != null) {
|
||||
Map<?, ?> img = (Map<?, ?>) list(j, "images").get(num(src, 0));
|
||||
byte[] data;
|
||||
if (img.get("uri") instanceof String u) {
|
||||
data = uri(u, files);
|
||||
} else {
|
||||
data = view(num(img.get("bufferView"), 0));
|
||||
}
|
||||
BufferedImage bi = Images.read(data);
|
||||
int[] px = argb(bi);
|
||||
if (f[0] != 1 || f[1] != 1 || f[2] != 1) {
|
||||
for (int k = 0; k < px.length; k++) {
|
||||
int c = px[k];
|
||||
px[k] = (c & 0xff000000)
|
||||
| c8((c >> 16 & 0xff) / 255.0 * f[0]) << 16
|
||||
| c8((c >> 8 & 0xff) / 255.0 * f[1]) << 8
|
||||
| c8((c & 0xff) / 255.0 * f[2]);
|
||||
}
|
||||
}
|
||||
return new MeshData.Material(name, rgb, px, bi.getWidth(), bi.getHeight(), dbl);
|
||||
}
|
||||
}
|
||||
return new MeshData.Material(name, rgb, null, 0, 0, dbl);
|
||||
}
|
||||
|
||||
byte[] view(int i) {
|
||||
Map<?, ?> v = (Map<?, ?>) list(j, "bufferViews").get(i);
|
||||
byte[] buf = buffers.get(num(v.get("buffer"), 0));
|
||||
int off = num(v.get("byteOffset"), 0);
|
||||
return Arrays.copyOfRange(buf, off, off + num(v.get("byteLength"), 0));
|
||||
}
|
||||
|
||||
double[][] accessor(int i) throws IOException {
|
||||
Map<?, ?> a = (Map<?, ?>) list(j, "accessors").get(i);
|
||||
int count = num(a.get("count"), 0);
|
||||
int comps =
|
||||
switch ((String) a.get("type")) {
|
||||
case "SCALAR" -> 1;
|
||||
case "VEC2" -> 2;
|
||||
case "VEC3" -> 3;
|
||||
case "VEC4" -> 4;
|
||||
default -> throw new IOException("unsupported accessor type " + a.get("type"));
|
||||
};
|
||||
int ct = num(a.get("componentType"), 5126);
|
||||
int size =
|
||||
switch (ct) {
|
||||
case 5120, 5121 -> 1;
|
||||
case 5122, 5123 -> 2;
|
||||
case 5125, 5126 -> 4;
|
||||
default -> throw new IOException("unsupported component type " + ct);
|
||||
};
|
||||
boolean norm = Boolean.TRUE.equals(a.get("normalized"));
|
||||
double[][] out = new double[count][comps];
|
||||
if (a.get("bufferView") == null) {
|
||||
return out;
|
||||
}
|
||||
Map<?, ?> v = (Map<?, ?>) list(j, "bufferViews").get(num(a.get("bufferView"), 0));
|
||||
byte[] buf = buffers.get(num(v.get("buffer"), 0));
|
||||
if (buf == null) {
|
||||
throw new IOException("buffer missing");
|
||||
}
|
||||
int base = num(v.get("byteOffset"), 0) + num(a.get("byteOffset"), 0);
|
||||
int stride = num(v.get("byteStride"), comps * size);
|
||||
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (int k = 0; k < count; k++) {
|
||||
for (int c = 0; c < comps; c++) {
|
||||
int o = base + k * stride + c * size;
|
||||
double x =
|
||||
switch (ct) {
|
||||
case 5120 -> norm ? Math.max(bb.get(o) / 127.0, -1) : bb.get(o);
|
||||
case 5121 -> norm ? (bb.get(o) & 0xff) / 255.0 : bb.get(o) & 0xff;
|
||||
case 5122 -> norm ? Math.max(bb.getShort(o) / 32767.0, -1) : bb.getShort(o);
|
||||
case 5123 -> norm ? (bb.getShort(o) & 0xffff) / 65535.0 : bb.getShort(o) & 0xffff;
|
||||
case 5125 -> Integer.toUnsignedLong(bb.getInt(o));
|
||||
default -> bb.getFloat(o);
|
||||
};
|
||||
out[k][c] = x;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Values;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Property values ↔ JSON values (platform.Json types). Arrays longer than {@link #INLINE} are
|
||||
* stored as base64 ({"i32": …}, {"f64": …}, {"bytes": …}); a changed array of the same length is
|
||||
* stored as runs of changed elements ({"runs": [[start, values], …]}) so unchanged (original)
|
||||
* data is never written.
|
||||
*/
|
||||
final class NodeCodec {
|
||||
static final int INLINE = 64;
|
||||
|
||||
private NodeCodec() {}
|
||||
|
||||
static Object encode(Prop p, Object v) {
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (p.type()) {
|
||||
case INT, FIXED, ANGLE, FLAGS, COLOR -> ((Integer) v).longValue();
|
||||
case DOUBLE -> v;
|
||||
case BOOL, STRING, CHOICE -> v;
|
||||
case REF -> ((Node) v).id();
|
||||
case INT_ARRAY -> ints((int[]) v);
|
||||
case DOUBLE_ARRAY -> doubles((double[]) v);
|
||||
case BYTES -> Map.of("bytes", Base64.getEncoder().encodeToString((byte[]) v));
|
||||
};
|
||||
}
|
||||
|
||||
private static Object ints(int[] a) {
|
||||
if (a.length <= INLINE) {
|
||||
List<Object> l = new ArrayList<>(a.length);
|
||||
for (int x : a) {
|
||||
l.add((long) x);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
ByteBuffer b = ByteBuffer.allocate(4 * a.length).order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (int x : a) {
|
||||
b.putInt(x);
|
||||
}
|
||||
return Map.of("i32", Base64.getEncoder().encodeToString(b.array()));
|
||||
}
|
||||
|
||||
private static Object doubles(double[] a) {
|
||||
if (a.length <= INLINE) {
|
||||
List<Object> l = new ArrayList<>(a.length);
|
||||
for (double x : a) {
|
||||
l.add(x);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
ByteBuffer b = ByteBuffer.allocate(8 * a.length).order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (double x : a) {
|
||||
b.putDouble(x);
|
||||
}
|
||||
return Map.of("f64", Base64.getEncoder().encodeToString(b.array()));
|
||||
}
|
||||
|
||||
/** Decodes a value; refs are resolved in scene (missing → IllegalArgumentException). */
|
||||
static Object decode(Prop p, Object j, Scene s, Object current) {
|
||||
if (j == null) {
|
||||
return null;
|
||||
}
|
||||
if (j instanceof Map<?, ?> m && m.containsKey("runs")) {
|
||||
return applyRuns(p, current, (List<?>) m.get("runs"));
|
||||
}
|
||||
return switch (p.type()) {
|
||||
case INT, FIXED, ANGLE, FLAGS, COLOR -> (int) ((Number) j).longValue();
|
||||
case DOUBLE -> ((Number) j).doubleValue();
|
||||
case BOOL -> (Boolean) j;
|
||||
case STRING, CHOICE -> (String) j;
|
||||
case REF -> {
|
||||
Node n = s.node((String) j);
|
||||
if (n == null) {
|
||||
throw new IllegalArgumentException("reference to unknown node " + j);
|
||||
}
|
||||
yield n;
|
||||
}
|
||||
case INT_ARRAY -> decodeInts(j);
|
||||
case DOUBLE_ARRAY -> decodeDoubles(j);
|
||||
case BYTES -> Base64.getDecoder().decode((String) ((Map<?, ?>) j).get("bytes"));
|
||||
};
|
||||
}
|
||||
|
||||
private static int[] decodeInts(Object j) {
|
||||
if (j instanceof Map<?, ?> m) {
|
||||
ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode((String) m.get("i32")))
|
||||
.order(ByteOrder.LITTLE_ENDIAN);
|
||||
int[] a = new int[b.remaining() / 4];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
a[i] = b.getInt();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
List<?> l = (List<?>) j;
|
||||
int[] a = new int[l.size()];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
a[i] = (int) ((Number) l.get(i)).longValue();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private static double[] decodeDoubles(Object j) {
|
||||
if (j instanceof Map<?, ?> m) {
|
||||
ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode((String) m.get("f64")))
|
||||
.order(ByteOrder.LITTLE_ENDIAN);
|
||||
double[] a = new double[b.remaining() / 8];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
a[i] = b.getDouble();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
List<?> l = (List<?>) j;
|
||||
double[] a = new double[l.size()];
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
a[i] = ((Number) l.get(i)).doubleValue();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Value for "changed from base to cur": runs for same-length arrays, else the full value. */
|
||||
static Object encodeChange(Prop p, Object base, Object cur) {
|
||||
if (p.type().isArray() && base != null && cur != null && length(base) == length(cur) && length(cur) > INLINE) {
|
||||
List<Object> runs = new ArrayList<>();
|
||||
int n = length(cur);
|
||||
int i = 0;
|
||||
while (i < n) {
|
||||
if (same(base, cur, i)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
int j = i;
|
||||
while (j < n && !same(base, cur, j)) {
|
||||
j++;
|
||||
}
|
||||
runs.add(List.of((long) i, encode(p, slice(cur, i, j))));
|
||||
i = j;
|
||||
}
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("runs", runs);
|
||||
return m;
|
||||
}
|
||||
return encode(p, cur);
|
||||
}
|
||||
|
||||
private static int length(Object a) {
|
||||
return switch (a) {
|
||||
case int[] x -> x.length;
|
||||
case double[] x -> x.length;
|
||||
case byte[] x -> x.length;
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean same(Object a, Object b, int i) {
|
||||
return switch (a) {
|
||||
case int[] x -> x[i] == ((int[]) b)[i];
|
||||
case double[] x -> Double.doubleToRawLongBits(x[i]) == Double.doubleToRawLongBits(((double[]) b)[i]);
|
||||
case byte[] x -> x[i] == ((byte[]) b)[i];
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static Object slice(Object a, int from, int to) {
|
||||
return switch (a) {
|
||||
case int[] x -> java.util.Arrays.copyOfRange(x, from, to);
|
||||
case double[] x -> java.util.Arrays.copyOfRange(x, from, to);
|
||||
case byte[] x -> java.util.Arrays.copyOfRange(x, from, to);
|
||||
default -> throw new IllegalArgumentException();
|
||||
};
|
||||
}
|
||||
|
||||
private static Object applyRuns(Prop p, Object current, List<?> runs) {
|
||||
Object out = Values.copy(current);
|
||||
for (Object r : runs) {
|
||||
List<?> run = (List<?>) r;
|
||||
int start = (int) ((Number) run.get(0)).longValue();
|
||||
Object vals = decode(p, run.get(1), null, null);
|
||||
System.arraycopy(vals, 0, out, start, length(vals));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A loaded {@code .llamalevel} project: the scene plus what the file carries besides it (thumbnail,
|
||||
* fields of newer format versions that are written back unchanged) and load warnings.
|
||||
*/
|
||||
public final class Project {
|
||||
private final Scene scene;
|
||||
final Scene reference;
|
||||
private byte[] thumbnail;
|
||||
final Map<String, Object> unknownTop = new LinkedHashMap<>();
|
||||
final SceneDiff.Unknown unknownNodes = new SceneDiff.Unknown();
|
||||
private final List<String> warnings = new ArrayList<>();
|
||||
|
||||
/** reference: the unedited scene the project is stored against (same kind and base). */
|
||||
public Project(Scene reference, Scene scene) {
|
||||
this.reference = reference;
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* New project for an original level (kind "edit") or an empty level (kind "new", without the base
|
||||
* level's teleport zones, point lights and region masks: {@link Templates#clearBaseLeftovers}).
|
||||
*/
|
||||
public static Project create(Bases cd, String kind, String base) throws java.io.IOException {
|
||||
Scene scene = Templates.reference(cd, kind, base);
|
||||
if (LevelSettings.NEW.equals(kind)) {
|
||||
Templates.clearBaseLeftovers(scene);
|
||||
}
|
||||
return new Project(Templates.reference(cd, kind, base), scene);
|
||||
}
|
||||
|
||||
public Scene scene() {
|
||||
return scene;
|
||||
}
|
||||
|
||||
/** The unedited scene the project is diffed against (extensions that change both use it). */
|
||||
public Scene reference() {
|
||||
return reference;
|
||||
}
|
||||
|
||||
/** Top-level level.json entries not written by the core (kept on save; extensions store data here). */
|
||||
public Map<String, Object> extra() {
|
||||
return unknownTop;
|
||||
}
|
||||
|
||||
/** PNG or {@code null}. */
|
||||
public byte[] thumbnail() {
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
public void setThumbnail(byte[] png) {
|
||||
thumbnail = png;
|
||||
}
|
||||
|
||||
/** Problems found while loading (base level changed, unknown references, …). */
|
||||
public List<String> warnings() {
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* {@code .llamalevel} files (zip: level.json + assets/ + thumbnail.png), see notes/editor-format.md.
|
||||
* Only differences to the reference scene are stored, so a project contains no original game data.
|
||||
*/
|
||||
public final class ProjectIO {
|
||||
public static final int FORMAT_VERSION = 1;
|
||||
public static final String EXTENSION = ".llamalevel";
|
||||
static final String LEVEL_JSON = "level.json";
|
||||
static final String THUMBNAIL = "thumbnail.png";
|
||||
private static final long ZIP_TIME = 946684800000L;
|
||||
|
||||
private static final List<String> TOP_KEYS = List.of(
|
||||
"formatVersion",
|
||||
"app",
|
||||
"kind",
|
||||
"base",
|
||||
"baseSha256",
|
||||
"name",
|
||||
"targetChapter",
|
||||
"nextId",
|
||||
"settings",
|
||||
"regions",
|
||||
"nodes");
|
||||
|
||||
private ProjectIO() {}
|
||||
|
||||
/**
|
||||
* Extension that adjusts reference and current scene identically before the stored changes are
|
||||
* applied (e.g. object types imported from other levels, keyed by a top-level level.json entry).
|
||||
*/
|
||||
public interface LoadHook {
|
||||
void beforeChanges(Map<String, Object> json, Bases cd, Scene reference, Scene scene, List<String> warnings)
|
||||
throws IOException;
|
||||
}
|
||||
|
||||
private static final List<LoadHook> HOOKS = new CopyOnWriteArrayList<>();
|
||||
|
||||
static {
|
||||
try {
|
||||
Class.forName("com.eliasmueller.llamamod.editor.tools.catalog.CrossImport");
|
||||
} catch (ClassNotFoundException | LinkageError e) {
|
||||
// optional extension
|
||||
}
|
||||
}
|
||||
|
||||
public static void addLoadHook(LoadHook h) {
|
||||
HOOKS.add(h);
|
||||
}
|
||||
|
||||
public static Project load(Path file, Bases cd) throws IOException {
|
||||
return read(Files.readAllBytes(file), cd);
|
||||
}
|
||||
|
||||
/** Reads a project; a damaged file (wrong value types, broken references) gives an IOException. */
|
||||
public static Project read(byte[] zip, Bases cd) throws IOException {
|
||||
try {
|
||||
return readChecked(zip, cd);
|
||||
} catch (RuntimeException e) {
|
||||
throw damaged(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException damaged(RuntimeException e) {
|
||||
return new IOException("the project file is damaged: " + e, e);
|
||||
}
|
||||
|
||||
private static int intOr(Map<String, Object> m, String key, long def) {
|
||||
return ((Number) m.getOrDefault(key, def)).intValue();
|
||||
}
|
||||
|
||||
private static Project readChecked(byte[] zip, Bases cd) throws IOException {
|
||||
Map<String, byte[]> files = unzip(zip);
|
||||
Map<String, Object> json = json(files);
|
||||
int ver = intOr(json, "formatVersion", 1);
|
||||
String kind = (String) json.getOrDefault("kind", LevelSettings.EDIT);
|
||||
String base = (String) json.getOrDefault("base", Templates.DEFAULT_BASE);
|
||||
Scene ref = Templates.reference(cd, kind, base);
|
||||
Scene cur = Templates.reference(cd, kind, base);
|
||||
Project p = new Project(ref, cur);
|
||||
if (ver > FORMAT_VERSION) {
|
||||
p.warnings().add("the project was written by a newer editor (format " + ver + "); unknown parts are kept");
|
||||
}
|
||||
String sha = (String) json.get("baseSha256");
|
||||
if (sha != null && !sha.equals(sha256(cd.wad(base)))) {
|
||||
p.warnings().add("the base level " + base + " differs from the one the project was made with");
|
||||
}
|
||||
for (Map.Entry<String, byte[]> e : files.entrySet()) {
|
||||
if (e.getKey().startsWith("assets/")) {
|
||||
cur.assets().put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
for (LoadHook h : HOOKS) {
|
||||
h.beforeChanges(json, cd, ref, cur, p.warnings());
|
||||
}
|
||||
cur.setNextIdCounter(intOr(json, "nextId", 1));
|
||||
SceneDiff.apply(cur, json, cur.assets(), p.unknownNodes, p.warnings());
|
||||
cur.assets().keySet().removeIf(k -> k.startsWith("assets/pages/"));
|
||||
for (Map.Entry<String, Object> e : json.entrySet()) {
|
||||
if (!TOP_KEYS.contains(e.getKey())) {
|
||||
p.unknownTop.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
p.setThumbnail(files.get(THUMBNAIL));
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Header only (no base level needed). */
|
||||
public static ProjectInfo info(byte[] zip) throws IOException {
|
||||
try {
|
||||
return infoChecked(zip);
|
||||
} catch (RuntimeException e) {
|
||||
throw damaged(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ProjectInfo infoChecked(byte[] zip) throws IOException {
|
||||
Map<String, Object> json = json(unzip(zip));
|
||||
Map<String, Object> st = (Map<String, Object>) json.getOrDefault("settings", Map.of());
|
||||
return new ProjectInfo(
|
||||
intOr(json, "formatVersion", 1),
|
||||
(String) json.getOrDefault("kind", LevelSettings.EDIT),
|
||||
(String) json.getOrDefault("base", Templates.DEFAULT_BASE),
|
||||
(String) json.getOrDefault("name", ""),
|
||||
(String) st.getOrDefault("author", ""),
|
||||
(String) st.getOrDefault("description", ""),
|
||||
intOr(json, "targetChapter", 0));
|
||||
}
|
||||
|
||||
public static void save(Project p, Path file, Bases cd) throws IOException {
|
||||
byte[] d = write(p, cd);
|
||||
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
Files.write(tmp, d);
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
public static byte[] write(Project p, Bases cd) throws IOException {
|
||||
Scene s = p.scene();
|
||||
LevelSettings st = s.settings();
|
||||
Map<String, byte[]> assets = new TreeMap<>(s.assets());
|
||||
Map<String, Object> json = new LinkedHashMap<>();
|
||||
json.put("formatVersion", (long) FORMAT_VERSION);
|
||||
json.put("app", "LlamaMod level editor");
|
||||
json.put("kind", st.projectKind());
|
||||
json.put("base", st.base());
|
||||
json.put("baseSha256", sha256(cd.wad(st.base())));
|
||||
json.put("name", st.levelName());
|
||||
json.put("targetChapter", (long) st.targetChapter());
|
||||
json.put("nextId", (long) s.nextIdCounter());
|
||||
json.putAll(SceneDiff.diff(p.reference, s, assets, p.unknownNodes));
|
||||
p.unknownTop.forEach(json::putIfAbsent);
|
||||
Map<String, byte[]> files = new TreeMap<>(assets);
|
||||
files.put(LEVEL_JSON, Json.write(json).getBytes(StandardCharsets.UTF_8));
|
||||
if (p.thumbnail() != null) {
|
||||
files.put(THUMBNAIL, p.thumbnail());
|
||||
}
|
||||
ByteArrayOutputStream bo = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream z = new ZipOutputStream(bo)) {
|
||||
for (String name : order(files)) {
|
||||
ZipEntry e = new ZipEntry(name);
|
||||
e.setTime(ZIP_TIME);
|
||||
z.putNextEntry(e);
|
||||
z.write(files.get(name));
|
||||
z.closeEntry();
|
||||
}
|
||||
}
|
||||
return bo.toByteArray();
|
||||
}
|
||||
|
||||
/** level.json first, then the thumbnail, then assets (sorted). */
|
||||
private static List<String> order(Map<String, byte[]> files) {
|
||||
return files.keySet().stream()
|
||||
.sorted(Comparator.comparingInt((String n) -> n.equals(LEVEL_JSON) ? 0 : n.equals(THUMBNAIL) ? 1 : 2)
|
||||
.thenComparing(Comparator.naturalOrder()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> json(Map<String, byte[]> files) throws IOException {
|
||||
byte[] j = files.get(LEVEL_JSON);
|
||||
if (j == null) {
|
||||
throw new IOException("not a level project (level.json missing)");
|
||||
}
|
||||
try {
|
||||
return (Map<String, Object>) Json.parse(new String(j, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("level.json is damaged: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, byte[]> unzip(byte[] zip) throws IOException {
|
||||
Map<String, byte[]> out = new LinkedHashMap<>();
|
||||
try (ZipInputStream z = new ZipInputStream(new ByteArrayInputStream(zip))) {
|
||||
ZipEntry e;
|
||||
while ((e = z.getNextEntry()) != null) {
|
||||
String n = e.getName();
|
||||
if (e.isDirectory() || n.contains("..") || n.startsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
out.put(n, z.readAllBytes());
|
||||
}
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
throw new IOException("not a level project (empty or not a zip file)");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static String sha256(byte[] d) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(d));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
/** Header of a project file, read without the game data (mod lists). */
|
||||
public record ProjectInfo(
|
||||
int formatVersion,
|
||||
String kind,
|
||||
String base,
|
||||
String name,
|
||||
String author,
|
||||
String description,
|
||||
int targetChapter) {
|
||||
public boolean isNewLevel() {
|
||||
return "new".equals(kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.BrushKind;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeList;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.PropType;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Values;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Differences between a reference scene (original level or empty template) and the edited scene,
|
||||
* as JSON-ready maps (the "settings", "regions" and "nodes" parts of level.json, see
|
||||
* notes/editor-format.md).
|
||||
*/
|
||||
final class SceneDiff {
|
||||
private SceneDiff() {}
|
||||
|
||||
/** Unknown node fields read from a newer file, kept for writing back: id → field → value. */
|
||||
static final class Unknown {
|
||||
final Map<String, Map<String, Object>> fields = new HashMap<>();
|
||||
}
|
||||
|
||||
/** Diff; added texture pages put their pixels as PNG into assets. */
|
||||
static Map<String, Object> diff(Scene ref, Scene cur, Map<String, byte[]> assets, Unknown unknown) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
Map<String, Object> st = changed(ref.settings(), cur.settings(), unknown);
|
||||
if (!st.isEmpty()) {
|
||||
out.put("settings", st);
|
||||
}
|
||||
Map<String, Object> rg = changed(ref.regions(), cur.regions(), unknown);
|
||||
if (!rg.isEmpty()) {
|
||||
out.put("regions", rg);
|
||||
}
|
||||
Map<String, Object> nodes = new LinkedHashMap<>();
|
||||
for (NodeKind k : Scene.LIST_KINDS) {
|
||||
NodeList<Node> rl = ref.listOf(k);
|
||||
NodeList<Node> cl = cur.listOf(k);
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
List<Object> removed = new ArrayList<>();
|
||||
Map<String, Object> changedNodes = new LinkedHashMap<>();
|
||||
List<String> expected = new ArrayList<>();
|
||||
for (Node r : rl) {
|
||||
Node c = cur.node(r.id());
|
||||
if (c == null || c.kind() != k) {
|
||||
removed.add(r.id());
|
||||
continue;
|
||||
}
|
||||
expected.add(r.id());
|
||||
Map<String, Object> ch = changed(r, c, unknown);
|
||||
if (!ch.isEmpty()) {
|
||||
changedNodes.put(r.id(), ch);
|
||||
}
|
||||
}
|
||||
List<Object> added = new ArrayList<>();
|
||||
for (Node c : cl) {
|
||||
if (ref.node(c.id()) == null) {
|
||||
added.add(full(c, assets, unknown));
|
||||
expected.add(c.id());
|
||||
}
|
||||
}
|
||||
List<String> actual = cl.stream().map(Node::id).toList();
|
||||
if (!removed.isEmpty()) {
|
||||
d.put("removed", removed);
|
||||
}
|
||||
if (!changedNodes.isEmpty()) {
|
||||
d.put("changed", changedNodes);
|
||||
}
|
||||
if (!added.isEmpty()) {
|
||||
d.put("added", added);
|
||||
}
|
||||
if (!actual.equals(expected)) {
|
||||
d.put("order", new ArrayList<Object>(actual));
|
||||
}
|
||||
if (!d.isEmpty()) {
|
||||
nodes.put(k.prefix(), d);
|
||||
}
|
||||
}
|
||||
if (!nodes.isEmpty()) {
|
||||
out.put("nodes", nodes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean editable(Prop p) {
|
||||
return p.persistent() && p.settable();
|
||||
}
|
||||
|
||||
private static Map<String, Object> changed(Node r, Node c, Unknown unknown) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
for (Prop p : c.props()) {
|
||||
if (!editable(p)) {
|
||||
continue;
|
||||
}
|
||||
Object a = p.get(r);
|
||||
Object b = p.get(c);
|
||||
boolean same = p.type() == PropType.REF ? idOf(a).equals(idOf(b)) : Values.same(a, b);
|
||||
if (!same) {
|
||||
m.put(p.name(), NodeCodec.encodeChange(p, a, b));
|
||||
}
|
||||
}
|
||||
return withUnknown(m, c, unknown);
|
||||
}
|
||||
|
||||
private static String idOf(Object n) {
|
||||
return n == null ? "" : ((Node) n).id();
|
||||
}
|
||||
|
||||
private static Map<String, Object> full(Node c, Map<String, byte[]> assets, Unknown unknown) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", c.id());
|
||||
if (c instanceof Brush b) {
|
||||
m.put("brushKind", b.brushKind().name());
|
||||
}
|
||||
for (Prop p : c.props()) {
|
||||
if (!editable(p) || p.name().equals("brushKind")) {
|
||||
continue;
|
||||
}
|
||||
Object v = p.get(c);
|
||||
if (c instanceof TexturePage pg && p.name().equals("pixels") && pg.flags() != 0) {
|
||||
String path = "assets/pages/" + c.id().replace(':', '_') + ".png";
|
||||
assets.put(path, Images.pagePng(pg));
|
||||
m.put(p.name(), Map.of("png", path));
|
||||
continue;
|
||||
}
|
||||
m.put(p.name(), NodeCodec.encode(p, v));
|
||||
}
|
||||
return withUnknown(m, c, unknown);
|
||||
}
|
||||
|
||||
/** Adds the unknown fields kept for node c (written back unchanged). */
|
||||
private static Map<String, Object> withUnknown(Map<String, Object> m, Node c, Unknown unknown) {
|
||||
Map<String, Object> u = unknown == null ? null : unknown.fields.get(c.id());
|
||||
if (u != null) {
|
||||
u.forEach(m::putIfAbsent);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Applies a diff to the reference scene (which becomes the edited scene). */
|
||||
@SuppressWarnings("unchecked")
|
||||
static void apply(
|
||||
Scene s, Map<String, Object> json, Map<String, byte[]> assets, Unknown unknown, List<String> warn) {
|
||||
Map<String, Object> nodes = (Map<String, Object>) json.getOrDefault("nodes", Map.of());
|
||||
// 1. removals, 2. create added nodes (all kinds, so references resolve), 3. set values
|
||||
List<Map.Entry<Node, Map<String, Object>>> fill = new ArrayList<>();
|
||||
for (NodeKind k : Scene.LIST_KINDS) {
|
||||
Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix());
|
||||
if (d == null) {
|
||||
continue;
|
||||
}
|
||||
for (Object id : (List<Object>) d.getOrDefault("removed", List.of())) {
|
||||
Node n = s.node((String) id);
|
||||
if (n == null) {
|
||||
warn.add("removed " + id + " does not exist in the base level");
|
||||
} else {
|
||||
s.remove(n);
|
||||
}
|
||||
}
|
||||
for (Object o : (List<Object>) d.getOrDefault("added", List.of())) {
|
||||
Map<String, Object> m = (Map<String, Object>) o;
|
||||
String id = (String) m.get("id");
|
||||
if (s.node(id) != null) {
|
||||
warn.add("added node " + id + " already exists – skipped");
|
||||
continue;
|
||||
}
|
||||
BrushKind bk = k == NodeKind.BRUSH ? BrushKind.valueOf((String) m.get("brushKind")) : null;
|
||||
Node n = Scene.createWithId(k, id, bk);
|
||||
s.add(n, -1);
|
||||
fill.add(Map.entry(n, m));
|
||||
}
|
||||
Map<String, Object> ch = (Map<String, Object>) d.getOrDefault("changed", Map.of());
|
||||
for (Map.Entry<String, Object> e : ch.entrySet()) {
|
||||
Node n = s.node(e.getKey());
|
||||
if (n == null) {
|
||||
warn.add("changed " + e.getKey() + " does not exist in the base level");
|
||||
continue;
|
||||
}
|
||||
fill.add(Map.entry(n, (Map<String, Object>) e.getValue()));
|
||||
}
|
||||
}
|
||||
if (json.get("settings") instanceof Map<?, ?> m) {
|
||||
fill.add(Map.entry(s.settings(), (Map<String, Object>) m));
|
||||
}
|
||||
if (json.get("regions") instanceof Map<?, ?> m) {
|
||||
fill.add(Map.entry(s.regions(), (Map<String, Object>) m));
|
||||
}
|
||||
for (Map.Entry<Node, Map<String, Object>> e : fill) {
|
||||
Node n = e.getKey();
|
||||
Set<String> known = new HashSet<>(List.of("id", "brushKind"));
|
||||
for (Prop p : n.props()) {
|
||||
known.add(p.name());
|
||||
if (!e.getValue().containsKey(p.name()) || !editable(p)) {
|
||||
continue;
|
||||
}
|
||||
Object j = e.getValue().get(p.name());
|
||||
Object v;
|
||||
if (n instanceof TexturePage pg
|
||||
&& p.name().equals("pixels")
|
||||
&& j instanceof Map<?, ?> pm
|
||||
&& pm.containsKey("png")) {
|
||||
byte[] png = assets.get((String) pm.get("png"));
|
||||
if (png == null) {
|
||||
warn.add(n.id() + ": page image " + pm.get("png") + " missing");
|
||||
continue;
|
||||
}
|
||||
v = Images.pagePixels(png, pg.width(), pg.height());
|
||||
} else {
|
||||
v = NodeCodec.decode(p, j, s, p.get(n));
|
||||
}
|
||||
try {
|
||||
p.setRaw(n, v);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
warn.add(n.id() + "." + p.name() + ": " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, Object> f : e.getValue().entrySet()) {
|
||||
if (!known.contains(f.getKey())) {
|
||||
unknown.fields
|
||||
.computeIfAbsent(n.id(), x -> new LinkedHashMap<>())
|
||||
.put(f.getKey(), f.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (NodeKind k : Scene.LIST_KINDS) {
|
||||
Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix());
|
||||
if (d == null || !d.containsKey("order")) {
|
||||
continue;
|
||||
}
|
||||
List<Node> order = new ArrayList<>();
|
||||
for (Object id : (List<Object>) d.get("order")) {
|
||||
Node n = s.node((String) id);
|
||||
if (n != null && n.kind() == k) {
|
||||
order.add(n);
|
||||
}
|
||||
}
|
||||
for (Node n : s.listOf(k)) {
|
||||
if (!order.contains(n)) {
|
||||
order.add(n);
|
||||
}
|
||||
}
|
||||
s.reorder(k, order);
|
||||
}
|
||||
for (var st : s.strats()) {
|
||||
st.setTypeName(s.catalog().nameOf(st.code()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Reference scenes projects are stored against: an original level, or an empty level on a base. */
|
||||
public final class Templates {
|
||||
/** Default base for new levels: the village (object code, textures and sound of world 1). */
|
||||
public static final String DEFAULT_BASE = "t1l1m001";
|
||||
|
||||
/** Strat pool reserve for new levels (levelbuild: objects + 80). */
|
||||
public static final int NEW_LEVEL_RESERVE = 80;
|
||||
|
||||
private Templates() {}
|
||||
|
||||
/** The original level as a scene (kind "edit"). */
|
||||
public static Scene original(Bases cd, String base) throws IOException {
|
||||
String b = base.toLowerCase(Locale.ROOT);
|
||||
return WadImporter.importWad(cd.wad(b), b, cd::fileOrNull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty level (kind "new") on a base level: tiles, tile models, objects and waypoints removed,
|
||||
* visibility regions off (one region that sees everything). Textures, zones (fog, music),
|
||||
* lights, object code and sound of the base stay.
|
||||
*/
|
||||
public static Scene newLevel(Bases cd, String base) throws IOException {
|
||||
Scene s = original(cd, base);
|
||||
makeEmpty(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
static void makeEmpty(Scene s) {
|
||||
s.clearQuiet(NodeKind.TILE);
|
||||
s.clearQuiet(NodeKind.TILE_MODEL);
|
||||
s.clearQuiet(NodeKind.STRAT);
|
||||
s.clearQuiet(NodeKind.PATH_NODE);
|
||||
LevelSettings st = s.settings();
|
||||
st.setRaw("kind", LevelSettings.NEW);
|
||||
st.setRaw("name", "New level");
|
||||
st.setRaw("targetChapter", 1);
|
||||
st.setRaw("poolReserve", NEW_LEVEL_RESERVE);
|
||||
int[] table = s.regions().table().clone();
|
||||
table[0] = 0xffffffff;
|
||||
s.regions().setRaw("table", table);
|
||||
s.regions().setRaw("grid", new int[st.width() * st.height()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes what a new level would inherit from the base level at the base's own positions: the
|
||||
* teleport zones (the start zone with its fog, ambient colour and music stays), the point lights
|
||||
* (the directional lights stay, they light the objects) and region masks (every region sees
|
||||
* everything). Textures and object code stay – STPC object models use the textures by index.
|
||||
* Applied to the scene of a new project, not to the reference, so saved projects keep their content.
|
||||
*/
|
||||
public static void clearBaseLeftovers(Scene s) {
|
||||
EnvZone start = s.zones().stream()
|
||||
.filter(EnvZone::isDefault)
|
||||
.findFirst()
|
||||
.orElse(s.zones().isEmpty() ? null : s.zones().get(0));
|
||||
for (EnvZone z : List.copyOf(s.zones())) {
|
||||
if (z != start) {
|
||||
s.remove(z);
|
||||
}
|
||||
}
|
||||
for (Light l : List.copyOf(s.lights())) {
|
||||
if (l.type() != Light.DIRECTIONAL) {
|
||||
s.remove(l);
|
||||
}
|
||||
}
|
||||
for (PathNode n : List.copyOf(s.pathNodes())) {
|
||||
s.remove(n);
|
||||
}
|
||||
int[] table = s.regions().table().clone();
|
||||
for (int i = 0; i < table.length; i += 2) {
|
||||
table[i] = 0xffffffff;
|
||||
}
|
||||
s.regions().setRaw("table", table);
|
||||
}
|
||||
|
||||
/** Reference scene of a project kind. */
|
||||
public static Scene reference(Bases cd, String kind, String base) throws IOException {
|
||||
return LevelSettings.NEW.equals(kind) ? newLevel(cd, base) : original(cd, base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Packs square images (power-of-two sizes ≤ 256) into new 256×256 texture pages (largest first
|
||||
* is the caller's job; slots are filled row by row per size).
|
||||
*/
|
||||
final class TextureAtlas {
|
||||
static final int PAGE = 256;
|
||||
/** Opaque RLE page / page with transparent pixels (formats of the original pages). */
|
||||
static final int OPAQUE = 0x81;
|
||||
|
||||
static final int ALPHA = 0x84;
|
||||
|
||||
record Slot(int page, int x, int y, int size) {}
|
||||
|
||||
private final List<TexturePage> pages = new ArrayList<>();
|
||||
private final List<boolean[]> used = new ArrayList<>();
|
||||
private final int firstIndex;
|
||||
private final Supplier<TexturePage> factory;
|
||||
|
||||
/** firstIndex: index the first new page will have in the TEXT page list. */
|
||||
TextureAtlas(int firstIndex, Supplier<TexturePage> factory) {
|
||||
this.firstIndex = firstIndex;
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
List<TexturePage> pages() {
|
||||
return pages;
|
||||
}
|
||||
|
||||
static int fitSize(int requested) {
|
||||
int s = 16;
|
||||
while (s * 2 <= Math.min(PAGE, Math.max(16, requested))) {
|
||||
s *= 2;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Places an image scaled to size×size; returns its slot (page = index in the full page list). */
|
||||
Slot add(BufferedImage img, int size) {
|
||||
int s = fitSize(size);
|
||||
int cells = PAGE / s;
|
||||
for (int p = 0; p <= pages.size(); p++) {
|
||||
if (p == pages.size()) {
|
||||
TexturePage pg = factory.get();
|
||||
pg.setRaw("flags", OPAQUE);
|
||||
pg.setRaw("pixels", new int[PAGE * PAGE]);
|
||||
pages.add(pg);
|
||||
used.add(new boolean[(PAGE / 16) * (PAGE / 16)]);
|
||||
}
|
||||
boolean[] u = used.get(p);
|
||||
int g = s / 16;
|
||||
for (int cy = 0; cy < cells; cy++) {
|
||||
for (int cx = 0; cx < cells; cx++) {
|
||||
if (free(u, cx * g, cy * g, g)) {
|
||||
mark(u, cx * g, cy * g, g);
|
||||
draw(pages.get(p), img, cx * s, cy * s, s);
|
||||
return new Slot(firstIndex + p, cx * s, cy * s, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("unreachable");
|
||||
}
|
||||
|
||||
private static boolean free(boolean[] u, int x, int y, int g) {
|
||||
int w = PAGE / 16;
|
||||
for (int j = y; j < y + g; j++) {
|
||||
for (int i = x; i < x + g; i++) {
|
||||
if (u[j * w + i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void mark(boolean[] u, int x, int y, int g) {
|
||||
int w = PAGE / 16;
|
||||
for (int j = y; j < y + g; j++) {
|
||||
for (int i = x; i < x + g; i++) {
|
||||
u[j * w + i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void draw(TexturePage pg, BufferedImage img, int ox, int oy, int s) {
|
||||
int[] px = Images.toAbgr1555(img, s, s);
|
||||
int[] dst = pg.pixels();
|
||||
boolean alpha = false;
|
||||
for (int y = 0; y < s; y++) {
|
||||
for (int x = 0; x < s; x++) {
|
||||
int v = px[y * s + x];
|
||||
dst[(oy + y) * PAGE + ox + x] = v;
|
||||
alpha |= v == 0 || (v & 0x8000) == 0;
|
||||
}
|
||||
}
|
||||
if (alpha) {
|
||||
pg.setRaw("flags", ALPHA);
|
||||
}
|
||||
pg.setEncodedCache(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureEntry;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
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 java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Images (PNG, JPEG) → new texture pages and texture table entries. Each image is scaled to a
|
||||
* square power-of-two size (16..256) and packed into new 256×256 pages; the entry covers the whole
|
||||
* image. Transparent pixels (alpha < 64) become 0x0000 (the engine's transparent colour), half
|
||||
* transparent ones STP = 0. Existing pages are never touched.
|
||||
*/
|
||||
public final class TextureImport {
|
||||
/** Normal opaque texture entry flags (most entries of the original levels). */
|
||||
public static final int ENTRY_FLAGS = 0x104;
|
||||
|
||||
private TextureImport() {}
|
||||
|
||||
public record NamedImage(String name, BufferedImage image) {}
|
||||
|
||||
/** command adds the pages and entries; entries[i] = texture index of image i after executing it. */
|
||||
public record Result(Command command, int[] entries, List<TexturePage> pages) {}
|
||||
|
||||
public static Result importImages(Scene s, List<NamedImage> images, int size) {
|
||||
List<TexturePage> pages = new ArrayList<>();
|
||||
TextureAtlas atlas = new TextureAtlas(s.pages().size(), () -> {
|
||||
TexturePage p = (TexturePage) s.create(NodeKind.TEXTURE_PAGE);
|
||||
pages.add(p);
|
||||
return p;
|
||||
});
|
||||
List<Command> cmds = new ArrayList<>();
|
||||
List<TextureEntry> entries = new ArrayList<>();
|
||||
int[] idx = new int[images.size()];
|
||||
for (int i = 0; i < images.size(); i++) {
|
||||
NamedImage ni = images.get(i);
|
||||
TextureAtlas.Slot slot = atlas.add(ni.image(), size);
|
||||
TextureEntry e = (TextureEntry) s.create(NodeKind.TEXTURE);
|
||||
e.setRaw("name", ni.name());
|
||||
e.setRaw("flags", ENTRY_FLAGS);
|
||||
e.setRaw("page", slot.page());
|
||||
e.setRaw("page2", -1);
|
||||
e.setRaw("u0", slot.x());
|
||||
e.setRaw("u1", slot.x() + slot.size() - 1);
|
||||
e.setRaw("v0", slot.y());
|
||||
e.setRaw("v1", slot.y() + slot.size() - 1);
|
||||
entries.add(e);
|
||||
idx[i] = s.textures().size() + i;
|
||||
}
|
||||
for (TexturePage p : pages) {
|
||||
p.setRaw("name", "imported");
|
||||
cmds.add(new AddNode(p));
|
||||
}
|
||||
for (TextureEntry e : entries) {
|
||||
cmds.add(new AddNode(e));
|
||||
}
|
||||
return new Result(new Batch("Import textures", cmds), idx, pages);
|
||||
}
|
||||
|
||||
public static Result importFiles(Scene s, List<Path> files, int size) throws IOException {
|
||||
List<NamedImage> l = new ArrayList<>();
|
||||
for (Path f : files) {
|
||||
String n = f.getFileName().toString();
|
||||
l.add(new NamedImage(
|
||||
n.contains(".") ? n.substring(0, n.lastIndexOf('.')) : n, Images.read(Files.readAllBytes(f))));
|
||||
}
|
||||
return importImages(s, l, size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.StratCatalog;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Checks what the engine needs before a build. Errors make the WAD unusable (or crash the game);
|
||||
* warnings are suspicious but buildable. Also usable by the UI for a problems list.
|
||||
*/
|
||||
public final class Validator {
|
||||
/** Vertex transform buffer limit per model (0x58b0f0..0x5d15f8, 36 bytes per vertex). */
|
||||
public static final int MAX_VERTS = 8000;
|
||||
|
||||
private Validator() {}
|
||||
|
||||
/** Errors; warnings are added to warn. */
|
||||
public static List<String> validate(Scene s, List<String> warn) {
|
||||
List<String> err = new ArrayList<>();
|
||||
LevelSettings st = s.settings();
|
||||
int w = st.width();
|
||||
int h = st.height();
|
||||
if (s.regions().grid().length != w * h) {
|
||||
err.add("region grid has " + s.regions().grid().length + " cells, the grid " + w * h);
|
||||
}
|
||||
if (s.regions().table().length != 64) {
|
||||
err.add("region table must have 64 values");
|
||||
}
|
||||
int nTex = s.textures().size();
|
||||
for (TileModel m : s.tileModels()) {
|
||||
if (m.vertexCount() > MAX_VERTS) {
|
||||
err.add(m.id() + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS + ")");
|
||||
}
|
||||
if (m.vertices().length % TileModel.VERTEX_STRIDE != 0
|
||||
|| m.faces().length % TileModel.FACE_STRIDE != 0
|
||||
|| m.planes().length != m.faceCount() * TileModel.PLANE_STRIDE) {
|
||||
err.add(m.id() + ": array lengths do not match");
|
||||
continue;
|
||||
}
|
||||
for (int g = 0; g < 3; g++) {
|
||||
if (m.collision(g).length % TileModel.COLLISION_SIZE != 0) {
|
||||
err.add(m.id() + ": collision group " + g + " is not a multiple of 32 bytes");
|
||||
}
|
||||
}
|
||||
int nv = m.vertexCount();
|
||||
for (int f = 0; f < m.faceCount(); f++) {
|
||||
for (int k = 0; k < 3; k++) {
|
||||
if (m.faceVertex(f, k) >= nv) {
|
||||
err.add(m.id() + " face " + f + ": vertex index out of range");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (m.faceTexture(f) >= nTex) {
|
||||
warn.add(m.id() + " face " + f + ": texture " + m.faceTexture(f) + " beyond the table (" + nTex
|
||||
+ ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean alts = (st.wfpc() & MapChunk.WF_ALTS) != 0;
|
||||
for (Tile t : s.tiles()) {
|
||||
if (t.model() == null || !s.contains(t.model())) {
|
||||
err.add(t.id() + ": model missing");
|
||||
continue;
|
||||
}
|
||||
int cx = t.cellX();
|
||||
int cz = t.cellZ();
|
||||
if (cx < 0 || cx >= w || cz < 0 || cz >= h) {
|
||||
err.add(t.id() + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")");
|
||||
}
|
||||
if (t.hasSwap()) {
|
||||
if (!alts) {
|
||||
err.add(t.id() + ": tile swaps need WFPC flag 0x10000, which this base level does not have");
|
||||
} else if (t.swapAltModel() == null || !s.contains(t.swapAltModel())) {
|
||||
err.add(t.id() + ": swap without alternative model");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Strat x : s.strats()) {
|
||||
if (x.waypoint() != null && !s.contains(x.waypoint())) {
|
||||
err.add(x.id() + ": waypoint is not part of the level");
|
||||
}
|
||||
StratCatalog.Type t = s.catalog().type(x.type());
|
||||
if (x.type().isEmpty()) {
|
||||
warn.add(x.id() + ": unknown strat code 0x" + Integer.toHexString(x.code()));
|
||||
} else if (t != null && t.variants().stream().noneMatch(v -> v.nParams() == x.params().length)) {
|
||||
warn.add(x.id() + " (" + x.type() + "): " + x.params().length + " parameters, the type uses "
|
||||
+ t.variants().getFirst().nParams());
|
||||
}
|
||||
double gx = x.gx();
|
||||
double gz = x.gz();
|
||||
if (gx < 0 || gx >= w || gz < 0 || gz >= h) {
|
||||
warn.add(x.id() + " (" + x.type() + ") is outside the grid");
|
||||
}
|
||||
}
|
||||
for (Brush b : s.brushes()) {
|
||||
String bad = b.enabled() ? BrushCompiler.problem(b, w, h, 8) : null;
|
||||
if (bad != null) {
|
||||
err.add(b.id() + " (" + b.label() + "): " + bad);
|
||||
}
|
||||
}
|
||||
for (PathNode n : s.pathNodes()) {
|
||||
if (n.next() != null && !s.contains(n.next())) {
|
||||
err.add(n.id() + ": next node is not part of the level");
|
||||
}
|
||||
}
|
||||
for (TexturePage p : s.pages()) {
|
||||
if (p.pixels().length != p.width() * p.height()) {
|
||||
err.add(p.id() + ": pixel count does not match " + p.width() + "×" + p.height());
|
||||
}
|
||||
}
|
||||
if (s.pages().size() > 127) {
|
||||
err.add("more than 127 texture pages");
|
||||
}
|
||||
if (st.isNewLevel() && (st.targetChapter() < 0 || st.targetChapter() > 49)) {
|
||||
err.add("TestLevel chapter must be 1..49");
|
||||
}
|
||||
return err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.RawChunk;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureEntry;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Scene → level WAD. Brushes and mesh placements are converted to tiles ({@link BrushCompiler})
|
||||
* and appended after the scene's own tiles and models. Without edits the result is byte-identical
|
||||
* to the imported WAD. The output is validated first ({@link Validator}) and parsed back after.
|
||||
*/
|
||||
public final class WadBuilder {
|
||||
private WadBuilder() {}
|
||||
|
||||
/** Statistics and non-fatal notes of a build. */
|
||||
public record Result(byte[] wad, Stats stats, List<String> warnings) {}
|
||||
|
||||
public record Stats(
|
||||
int models,
|
||||
int tiles,
|
||||
int generatedModels,
|
||||
int generatedTiles,
|
||||
int strats,
|
||||
int waypoints,
|
||||
int textures,
|
||||
int pages,
|
||||
int wadBytes) {}
|
||||
|
||||
public static byte[] build(Scene s) {
|
||||
return buildWithReport(s).wad();
|
||||
}
|
||||
|
||||
public static Result buildWithReport(Scene s) {
|
||||
List<String> warn = new ArrayList<>();
|
||||
List<String> errors = Validator.validate(s, warn);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalStateException("The level is not valid:\n " + String.join("\n ", errors));
|
||||
}
|
||||
BrushCompiler.Output gen = BrushCompiler.compile(s, warn);
|
||||
List<TileModel> models = new ArrayList<>(s.tileModels());
|
||||
models.addAll(gen.models());
|
||||
List<Tile> tiles =
|
||||
new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList());
|
||||
tiles.addAll(gen.tiles());
|
||||
List<TexturePage> pages = new ArrayList<>(s.pages());
|
||||
pages.addAll(gen.pages());
|
||||
List<TextureEntry> entries = new ArrayList<>(s.textures());
|
||||
entries.addAll(gen.textures());
|
||||
|
||||
List<Wad.Chunk> out = new ArrayList<>();
|
||||
boolean nameDone = false;
|
||||
for (RawChunk c : s.chunks()) {
|
||||
String tag = c.tag();
|
||||
byte[] d =
|
||||
switch (tag) {
|
||||
case "TRAK" -> ChunkWriter.trak(models);
|
||||
case "MAP " -> ChunkWriter.map(s, models, tiles);
|
||||
case "TEXT" -> c.managed() ? ChunkWriter.text(s, pages, entries) : c.data();
|
||||
case "LGHT" -> c.managed() ? ChunkWriter.lights(s) : c.data();
|
||||
case "NAME" -> name(s, c);
|
||||
default -> c.data();
|
||||
};
|
||||
nameDone |= tag.equals("NAME");
|
||||
out.add(new Wad.Chunk(tag, d));
|
||||
}
|
||||
if (!nameDone) {
|
||||
warn.add("the base level has no NAME chunk; the level name is not stored");
|
||||
}
|
||||
byte[] wad = new Wad(out).toBytes();
|
||||
Level chk = Level.parse(wad);
|
||||
if (chk.map().tiles.size() != tiles.size()
|
||||
|| chk.map().strats.size() != s.strats().size()) {
|
||||
throw new IllegalStateException("MAP does not parse back (tiles/strats)");
|
||||
}
|
||||
Stats st = new Stats(
|
||||
models.size(),
|
||||
tiles.size(),
|
||||
gen.models().size(),
|
||||
gen.tiles().size(),
|
||||
s.strats().size(),
|
||||
s.pathNodes().size(),
|
||||
entries.size(),
|
||||
pages.size(),
|
||||
wad.length);
|
||||
return new Result(wad, st, warn);
|
||||
}
|
||||
|
||||
private static byte[] name(Scene s, RawChunk c) {
|
||||
String n = s.settings().levelName();
|
||||
if (c.managed()) {
|
||||
return WadImporter.nameChunk(s.settings().nameChunkPrefix(), n);
|
||||
}
|
||||
byte[] d = c.data();
|
||||
Wad w = new Wad(List.of(new Wad.Chunk("NAME", d)));
|
||||
if (n.equals(w.levelName())) {
|
||||
return d;
|
||||
}
|
||||
return WadImporter.nameChunk(java.util.Arrays.copyOf(d, 4), n);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package com.eliasmueller.llamamod.editor.io;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.RawChunk;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.StratCatalog;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureAnim;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureEntry;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Face;
|
||||
import com.eliasmueller.llamamod.format.FormatException;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Model;
|
||||
import com.eliasmueller.llamamod.format.Stpc;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Trak;
|
||||
import com.eliasmueller.llamamod.format.Vertex;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Original level WAD → {@link Scene}. Node ids follow the position in the WAD ("tile:0", "strat:12",
|
||||
* …), so the same WAD always gives the same ids (projects store edits against them).
|
||||
*/
|
||||
public final class WadImporter {
|
||||
/** Chunks the editor regenerates; all others are kept verbatim. */
|
||||
static final Set<String> MANAGED = Set.of("TRAK", "MAP ", "TEXT", "LGHT", "NAME");
|
||||
|
||||
/**
|
||||
* Strat types placed in no instance of a level whose code nevertheless has them: type fields from
|
||||
* another level (as levelbuild does).
|
||||
*/
|
||||
private WadImporter() {}
|
||||
|
||||
/**
|
||||
* @param base level name, e.g. "t1l1m001" (stored as the project base)
|
||||
* @param cd loads other CD files by relative path for foreign strat types (may be {@code null})
|
||||
*/
|
||||
public static Scene importWad(byte[] wadBytes, String base, Function<String, byte[]> cd) {
|
||||
Wad wad = Wad.parse(wadBytes);
|
||||
Scene s = new Scene();
|
||||
LevelSettings st = s.settings();
|
||||
int wf = wad.wfpcFlags();
|
||||
st.setRaw("wfpc", wf);
|
||||
st.setRaw("base", base == null ? "" : base);
|
||||
st.setRaw("kind", LevelSettings.EDIT);
|
||||
|
||||
Set<String> managed = new HashSet<>();
|
||||
Level lv = Level.parse(wadBytes);
|
||||
importTrak(s, lv.models());
|
||||
managed.add("TRAK");
|
||||
importMap(s, lv);
|
||||
managed.add("MAP ");
|
||||
if (wad.has("TEXT")) {
|
||||
importText(s, wad.require("TEXT"));
|
||||
managed.add("TEXT");
|
||||
}
|
||||
if (wad.has("LGHT") && importLights(s, wad.require("LGHT"))) {
|
||||
managed.add("LGHT");
|
||||
}
|
||||
if (wad.has("NAME")) {
|
||||
byte[] nm = wad.require("NAME");
|
||||
String name = wad.levelName();
|
||||
if (name != null && Arrays.equals(nm, nameChunk(Arrays.copyOf(nm, 4), name))) {
|
||||
st.setRaw("name", name);
|
||||
st.setRaw("nameChunkPrefix", Arrays.copyOf(nm, 4));
|
||||
managed.add("NAME");
|
||||
} else if (name != null) {
|
||||
st.setRaw("name", name);
|
||||
}
|
||||
}
|
||||
int i = 0;
|
||||
for (Wad.Chunk c : wad.chunks()) {
|
||||
RawChunk r = new RawChunk("chunk:" + i++);
|
||||
r.setRaw("tag", c.tag());
|
||||
r.setRaw("data", managed.contains(c.tag()) ? null : c.data());
|
||||
s.addQuiet(r);
|
||||
}
|
||||
if (wad.has("STPC")) {
|
||||
s.setCatalog(catalog(wad.require("STPC"), wf, lv.map(), base, cd));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static byte[] nameChunk(byte[] prefix, String name) {
|
||||
byte[] n = name.getBytes(StandardCharsets.ISO_8859_1);
|
||||
byte[] out = new byte[4 + n.length + 1];
|
||||
System.arraycopy(prefix, 0, out, 0, 4);
|
||||
System.arraycopy(n, 0, out, 4, n.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
static TileModel tileModel(String id, Model m) {
|
||||
TileModel t = new TileModel(id);
|
||||
double[] v = new double[m.verts.size() * TileModel.VERTEX_STRIDE];
|
||||
int k = 0;
|
||||
for (Vertex x : m.verts) {
|
||||
v[k++] = x.x();
|
||||
v[k++] = x.y();
|
||||
v[k++] = x.z();
|
||||
v[k++] = x.nx();
|
||||
v[k++] = x.ny();
|
||||
v[k++] = x.nz();
|
||||
}
|
||||
int[] f = new int[m.faces.size() * TileModel.FACE_STRIDE];
|
||||
double[] p = new double[m.faces.size() * TileModel.PLANE_STRIDE];
|
||||
int a = 0;
|
||||
int b = 0;
|
||||
for (Face x : m.faces) {
|
||||
f[a++] = x.flags();
|
||||
f[a++] = x.v0();
|
||||
f[a++] = x.v1();
|
||||
f[a++] = x.v2();
|
||||
f[a++] = x.tex();
|
||||
f[a++] = x.pad();
|
||||
p[b++] = x.nx();
|
||||
p[b++] = x.ny();
|
||||
p[b++] = x.nz();
|
||||
p[b++] = x.d();
|
||||
}
|
||||
t.setRaw("vertices", v);
|
||||
t.setRaw("faces", f);
|
||||
t.setRaw("planes", p);
|
||||
for (int g = 0; g < 3; g++) {
|
||||
ByteArrayOutputStream bo = new ByteArrayOutputStream();
|
||||
for (byte[] r : m.coll.get(g)) {
|
||||
bo.writeBytes(r);
|
||||
}
|
||||
t.setRaw("collision" + g, bo.toByteArray());
|
||||
}
|
||||
t.setRaw("header", m.header == null ? null : Arrays.copyOf(m.header, 0x6c));
|
||||
return t;
|
||||
}
|
||||
|
||||
private static void importTrak(Scene s, List<Model> models) {
|
||||
for (int i = 0; i < models.size(); i++) {
|
||||
s.addQuiet(tileModel("model:" + i, models.get(i)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void importMap(Scene s, Level lv) {
|
||||
MapChunk m = lv.map();
|
||||
LevelSettings st = s.settings();
|
||||
List<TileModel> models = s.tileModels();
|
||||
int[] vc = Trak.vertCounts(lv.models());
|
||||
st.setRaw("width", m.width);
|
||||
st.setRaw("height", m.height);
|
||||
List<MapChunk.TileColors> cols = m.tileColors(vc);
|
||||
int[] altIdx = m.altIndexPerTile();
|
||||
for (int i = 0; i < m.tiles.size(); i++) {
|
||||
MapChunk.Tile mt = m.tiles.get(i);
|
||||
Tile t = new Tile("tile:" + i);
|
||||
if (m.tileModels[i] != mt.model()) {
|
||||
throw new FormatException("tile " + i + ": model index differs between MAP blocks");
|
||||
}
|
||||
t.setRaw("x", mt.x());
|
||||
t.setRaw("y", mt.y());
|
||||
t.setRaw("z", mt.z());
|
||||
t.setRaw("rot", mt.rot());
|
||||
t.setRaw("model", models.get(mt.model()));
|
||||
t.setRaw("alt", mt.alt());
|
||||
int[] fx = m.tilesFixed.get(i);
|
||||
t.setRaw(
|
||||
"fixed", Arrays.equals(fx, Tile.deriveFixed(mt.x(), mt.y(), mt.z(), mt.rot())) ? null : fx.clone());
|
||||
t.setRaw("colors", flatten(cols.get(i).frames()));
|
||||
if (altIdx[i] >= 0) {
|
||||
int[] a = m.alts.get(altIdx[i]);
|
||||
t.setRaw("swapCell", new int[] {a[0], a[1], a[2]});
|
||||
t.setRaw("swapModel", models.get(a[3]));
|
||||
t.setRaw("swapAltModel", models.get(a[4]));
|
||||
t.setRaw("altColors", flatten(cols.get(i).altFrames()));
|
||||
}
|
||||
s.addQuiet(t);
|
||||
}
|
||||
long flagged = m.tiles.stream().filter(x -> x.alt() != 0).count();
|
||||
if (m.alts.size() > flagged) {
|
||||
throw new FormatException("more tile swap entries than flagged tiles");
|
||||
}
|
||||
st.setRaw("paramPool", m.params.clone());
|
||||
for (int i = 0; i < m.envZones.size(); i++) {
|
||||
EnvZone z = new EnvZone("zone:" + i);
|
||||
int[] f = m.envZones.get(i).f();
|
||||
for (int k = 0; k < f.length; k++) {
|
||||
z.setRaw(EnvZone.FIELDS[k], f[k]);
|
||||
}
|
||||
s.addQuiet(z);
|
||||
}
|
||||
List<PathNode> nodes = importPathNodes(s, m);
|
||||
s.regions().setRaw("table", m.regionTable.clone());
|
||||
s.regions().setRaw("grid", m.regionGrid.clone());
|
||||
importStrats(s, m, nodes);
|
||||
st.setRaw("poolExtra", m.stratExtra);
|
||||
st.setRaw("poolReserve", m.stratExtra - m.strats.size());
|
||||
// everything after the strats (paths, WFPC 0x10 value, u16, rest) stays verbatim
|
||||
byte[] full = lv.wad().require("MAP");
|
||||
st.setRaw("mapTail", new byte[0]);
|
||||
byte[] head = ChunkWriter.map(s, models, s.tiles());
|
||||
if (head.length > full.length || !Arrays.equals(head, 0, head.length, full, 0, head.length)) {
|
||||
throw new FormatException("MAP does not rebuild (" + firstDiff(head, full) + ")");
|
||||
}
|
||||
st.setRaw("mapTail", Arrays.copyOfRange(full, head.length, full.length));
|
||||
}
|
||||
|
||||
private static List<PathNode> importPathNodes(Scene s, MapChunk m) {
|
||||
List<PathNode> nodes = new ArrayList<>();
|
||||
for (int i = 0; i < m.nodes.size(); i++) {
|
||||
nodes.add(new PathNode("wp:" + i));
|
||||
}
|
||||
for (int i = 0; i < m.nodes.size(); i++) {
|
||||
MapChunk.PathNode p = m.nodes.get(i);
|
||||
PathNode n = nodes.get(i);
|
||||
if (p.next() > 0 && p.next() < nodes.size()) {
|
||||
n.setRaw("next", nodes.get(p.next()));
|
||||
n.setRaw("nextRaw", 0);
|
||||
} else {
|
||||
n.setRaw("nextRaw", p.next());
|
||||
}
|
||||
n.setRaw("unk1", p.unk1());
|
||||
n.setRaw("rx", p.rx());
|
||||
n.setRaw("ry", p.ry());
|
||||
n.setRaw("rz", p.rz());
|
||||
n.setRaw("x", p.x());
|
||||
n.setRaw("y", p.y());
|
||||
n.setRaw("z", p.z());
|
||||
n.setRaw("unk2", p.unk2());
|
||||
n.setRaw("unk3", p.unk3());
|
||||
s.addQuiet(n);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private static void importStrats(Scene s, MapChunk m, List<PathNode> nodes) {
|
||||
for (int i = 0; i < m.strats.size(); i++) {
|
||||
MapChunk.Strat r = m.strats.get(i);
|
||||
Strat x = new Strat("strat:" + i);
|
||||
x.setRaw("code", r.code());
|
||||
x.setRaw("rx", r.rx());
|
||||
x.setRaw("ry", r.ry());
|
||||
x.setRaw("rz", r.rz());
|
||||
x.setRaw("x", r.x());
|
||||
x.setRaw("y", r.y());
|
||||
x.setRaw("z", r.z());
|
||||
int n = r.nParams();
|
||||
int pi = r.paramIndex();
|
||||
if (n > 0 && (pi < 0 || pi + n > m.params.length)) {
|
||||
throw new FormatException("strat " + i + ": parameters outside the pool");
|
||||
}
|
||||
x.setRaw("params", n == 0 ? new int[0] : Arrays.copyOfRange(m.params, pi, pi + n));
|
||||
x.setRaw("paramIndex", pi);
|
||||
x.setRaw("nVars", r.nVars());
|
||||
x.setRaw("slots", r.slots());
|
||||
x.setRaw("typeFlags", r.typeFlags());
|
||||
x.setRaw("extra", r.extra());
|
||||
if (r.waypoint() >= 0 && r.waypoint() < nodes.size()) {
|
||||
x.setRaw("waypoint", nodes.get(r.waypoint()));
|
||||
} else {
|
||||
x.setRaw("waypointRaw", r.waypoint());
|
||||
}
|
||||
x.setRaw("zero", r.zero());
|
||||
x.setRaw("flags", r.flags() & 0xffff);
|
||||
x.setRaw("last", r.last());
|
||||
s.addQuiet(x);
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstDiff(byte[] a, byte[] b) {
|
||||
int n = Math.min(a.length, b.length);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (a[i] != b[i]) {
|
||||
return "first difference at " + i;
|
||||
}
|
||||
}
|
||||
return "length " + a.length + " vs " + b.length;
|
||||
}
|
||||
|
||||
private static int[] flatten(List<int[]> frames) {
|
||||
int n = 0;
|
||||
for (int[] f : frames) {
|
||||
n += f.length;
|
||||
}
|
||||
int[] out = new int[n];
|
||||
int o = 0;
|
||||
for (int[] f : frames) {
|
||||
System.arraycopy(f, 0, out, o, f.length);
|
||||
o += f.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void importText(Scene s, byte[] d) {
|
||||
TextChunk tc = TextChunk.parse(d);
|
||||
int o = 0;
|
||||
int npal = Bytes.u32(d, o);
|
||||
o += 4;
|
||||
for (int i = 0; i < npal; i++) {
|
||||
o += 8 + 3 * Bytes.u32(d, o + 4);
|
||||
}
|
||||
s.settings().setRaw("textPalettes", Arrays.copyOfRange(d, 0, o));
|
||||
o += 4;
|
||||
for (int i = 0; i < tc.pages.size(); i++) {
|
||||
TextChunk.Page p = tc.pages.get(i);
|
||||
int start = o;
|
||||
int fl = Bytes.i32(d, o);
|
||||
int w = Bytes.u32(d, o + 4);
|
||||
int h = Bytes.u32(d, o + 8);
|
||||
o += 12;
|
||||
if (fl == 0) {
|
||||
o += w * h;
|
||||
} else if ((fl & 0x80) != 0) {
|
||||
o += 4 + Bytes.u32(d, o);
|
||||
} else {
|
||||
o += w * h * 2;
|
||||
}
|
||||
TexturePage pg = new TexturePage("page:" + i);
|
||||
pg.setRaw("flags", p.flags);
|
||||
pg.setRaw("width", p.w);
|
||||
pg.setRaw("height", p.h);
|
||||
pg.setRaw("pixels", p.pixels);
|
||||
pg.setEncodedCache(Arrays.copyOfRange(d, start, o));
|
||||
s.addQuiet(pg);
|
||||
}
|
||||
List<TextChunk.TextureEntry> te = TextChunk.textureEntries(d);
|
||||
Map<Integer, String> names = textureNames(s.settings().base());
|
||||
for (int i = 0; i < te.size(); i++) {
|
||||
TextureEntry t = textureEntry("tex:" + i, te.get(i));
|
||||
t.setRaw("name", names.getOrDefault(i, ""));
|
||||
s.addQuiet(t);
|
||||
}
|
||||
o += 4 + 8 * te.size();
|
||||
int na = Bytes.u32(d, o);
|
||||
o += 4;
|
||||
for (int i = 0; i < na; i++) {
|
||||
Bytes.need(d, o, 3);
|
||||
int tex = Bytes.u16(d, o);
|
||||
int type = d[o + 2] & 0xff;
|
||||
o += 3;
|
||||
int len = TextureAnim.fixedPayload(type);
|
||||
if (type == 8) {
|
||||
len = 3 + 2 * (d[o + 1] & 0xff);
|
||||
} else if (type == 10 || type == 11) {
|
||||
len = 4 + 2 * (d[o + 3] & 0xff);
|
||||
}
|
||||
Bytes.need(d, o, len);
|
||||
TextureAnim a = new TextureAnim("anim:" + i);
|
||||
a.setRaw("texture", tex);
|
||||
a.setRaw("type", type);
|
||||
a.setRaw("data", Arrays.copyOfRange(d, o, o + len));
|
||||
o += len;
|
||||
s.addQuiet(a);
|
||||
}
|
||||
s.settings().setRaw("textTail", Arrays.copyOfRange(d, o, d.length));
|
||||
}
|
||||
|
||||
static TextureEntry textureEntry(String id, TextChunk.TextureEntry e) {
|
||||
TextureEntry t = new TextureEntry(id);
|
||||
t.setRaw("flags", e.flags());
|
||||
t.setRaw("page", e.page());
|
||||
t.setRaw("page2", e.page2());
|
||||
t.setRaw("u0", e.u0());
|
||||
t.setRaw("u1", e.u1());
|
||||
t.setRaw("v0", e.v0());
|
||||
t.setRaw("v1", e.v1());
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Names of village textures (levelbuild names) for the outliner; other levels have none. */
|
||||
private static Map<Integer, String> textureNames(String base) {
|
||||
Map<Integer, String> m = new HashMap<>();
|
||||
if ("t1l1m001".equalsIgnoreCase(base)) {
|
||||
LevelBuilder.TEX.forEach((k, v) -> m.putIfAbsent(v, k));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** False if the chunk does not parse exactly (then it stays raw). */
|
||||
private static boolean importLights(Scene s, byte[] d) {
|
||||
try {
|
||||
int n = Bytes.u32(d, 0);
|
||||
int o = 4;
|
||||
List<Light> out = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
Bytes.need(d, o, 4);
|
||||
Light l = new Light("light:" + i);
|
||||
int type = d[o] & 0xff;
|
||||
l.setRaw("type", type);
|
||||
l.setRaw("r", d[o + 1] & 0xff);
|
||||
l.setRaw("g", d[o + 2] & 0xff);
|
||||
l.setRaw("b", d[o + 3] & 0xff);
|
||||
o += 4;
|
||||
if (type == Light.DIRECTIONAL || type == Light.POINT || type == Light.POINT_BRIGHT) {
|
||||
Bytes.need(d, o, 12);
|
||||
l.setRaw("x", (double) Bytes.f32(d, o));
|
||||
l.setRaw("y", (double) Bytes.f32(d, o + 4));
|
||||
l.setRaw("z", (double) Bytes.f32(d, o + 8));
|
||||
o += 12;
|
||||
}
|
||||
if (type == Light.POINT || type == Light.POINT_BRIGHT) {
|
||||
Bytes.need(d, o, 9);
|
||||
l.setRaw("a", (double) Bytes.f32(d, o));
|
||||
l.setRaw("rangeB", (double) Bytes.f32(d, o + 4));
|
||||
l.setRaw("c", d[o + 8] & 0xff);
|
||||
o += 9;
|
||||
}
|
||||
out.add(l);
|
||||
}
|
||||
for (Light l : out) {
|
||||
s.addQuiet(l);
|
||||
}
|
||||
s.settings().setRaw("lghtTail", Arrays.copyOfRange(d, o, d.length));
|
||||
return Arrays.equals(ChunkWriter.lights(s), d);
|
||||
} catch (FormatException e) {
|
||||
s.clearQuiet(NodeKind.LIGHT);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Placeable types of the level (levelbuild's prototype rules). */
|
||||
static StratCatalog catalog(byte[] stpc, int wf, MapChunk map, String base, Function<String, byte[]> cd) {
|
||||
StratCatalog c = new StratCatalog();
|
||||
int lo = Stpc.codeStart(stpc, wf);
|
||||
for (MapChunk.Strat s : map.strats) {
|
||||
String nm = Strats.nameOr(stpc, s.code(), lo, "?@" + Integer.toHexString(s.code()))
|
||||
.replace("(spawn)", "");
|
||||
c.add(nm, variant(s, map, base));
|
||||
}
|
||||
Map<String, Integer> named = new HashMap<>();
|
||||
for (Strats.Named n : Strats.allNamed(stpc, lo)) {
|
||||
named.putIfAbsent(n.name(), n.offset());
|
||||
c.addCodeOnly(n.name(), n.offset());
|
||||
}
|
||||
if (cd != null) {
|
||||
for (Map.Entry<String, String> f : Strats.BORROWED.entrySet()) {
|
||||
String nm = f.getKey();
|
||||
if (c.has(nm) || !named.containsKey(nm) || f.getValue().contains(base == null ? "?" : base)) {
|
||||
continue;
|
||||
}
|
||||
Strats.Borrowed b = Strats.borrow(nm, f.getValue(), cd, named.get(nm));
|
||||
if (b != null) {
|
||||
c.add(nm, variant(b.strat(), b.map(), f.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private static StratCatalog.Variant variant(MapChunk.Strat s, MapChunk map, String src) {
|
||||
int[] prm = s.nParams() > 0 && s.paramIndex() >= 0 && s.paramIndex() + s.nParams() <= map.params.length
|
||||
? Arrays.copyOfRange(map.params, s.paramIndex(), s.paramIndex() + s.nParams())
|
||||
: new int[0];
|
||||
List<int[]> chain = new ArrayList<>();
|
||||
int k = s.waypoint();
|
||||
while (k >= 0 && k < map.nodes.size() && chain.size() <= 200) {
|
||||
MapChunk.PathNode n = map.nodes.get(k);
|
||||
chain.add(new int[] {
|
||||
n.x() - s.x(), n.y() - s.y(), n.z() - s.z(), n.rx(), n.ry(), n.rz(), n.unk1(), n.unk2(), n.unk3()
|
||||
});
|
||||
if (n.next() == 0) {
|
||||
break;
|
||||
}
|
||||
k = n.next();
|
||||
}
|
||||
return new StratCatalog.Variant(
|
||||
s.code(),
|
||||
s.nVars(),
|
||||
s.slots(),
|
||||
s.typeFlags(),
|
||||
s.extra(),
|
||||
s.flags() & 0xffff,
|
||||
s.rx(),
|
||||
s.ry(),
|
||||
s.rz(),
|
||||
prm,
|
||||
List.copyOf(chain),
|
||||
src);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
/** Axis-aligned bounds as {x0, y0, z0, x1, y1, z1}. */
|
||||
final class Bounds {
|
||||
private Bounds() {}
|
||||
|
||||
/** Empty bounds (+∞ minimum, −∞ maximum) to {@link #grow}. */
|
||||
static double[] empty() {
|
||||
double p = Double.POSITIVE_INFINITY;
|
||||
double n = Double.NEGATIVE_INFINITY;
|
||||
return new double[] {p, p, p, n, n, n};
|
||||
}
|
||||
|
||||
/** Extends axis k (0 = x, 1 = y, 2 = z) to include v. */
|
||||
static void grow(double[] b, int k, double v) {
|
||||
b[k] = Math.min(b[k], v);
|
||||
b[k + 3] = Math.max(b[k + 3], v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Primitive building block converted to tiles (TRAK models, MAP tiles, vertex colours, collision)
|
||||
* when the level is built. Brushes are replayed in list order, so the order matters for overlapping
|
||||
* geometry and for the random texture rotation. Parameters depend on {@link #brushKind()}; every
|
||||
* parameter is a {@link Prop} named like the {@link BrushKind.Param}.
|
||||
*/
|
||||
public final class Brush extends Node {
|
||||
BrushKind brushKind;
|
||||
String name = "";
|
||||
boolean enabled = true;
|
||||
final Map<String, Object> values = new LinkedHashMap<>();
|
||||
|
||||
private static final Map<BrushKind, List<Prop>> PROPS = new EnumMap<>(BrushKind.class);
|
||||
|
||||
public Brush(String id, BrushKind kind) {
|
||||
super(id);
|
||||
brushKind = kind;
|
||||
for (BrushKind.Param p : kind.params()) {
|
||||
Object d = p.def();
|
||||
values.put(p.name(), d instanceof double[] a ? a.clone() : d instanceof int[] ia ? ia.clone() : d);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.BRUSH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
synchronized (PROPS) {
|
||||
return PROPS.computeIfAbsent(brushKind, Brush::define);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Prop> define(BrushKind k) {
|
||||
Props<Brush> l = new Props<Brush>()
|
||||
.group("Brush")
|
||||
.p(
|
||||
"brushKind",
|
||||
"Kind",
|
||||
PropType.CHOICE,
|
||||
n -> n.brushKind.name(),
|
||||
(n, v) -> n.brushKind = BrushKind.valueOf(v),
|
||||
b -> b.choices(Arrays.stream(BrushKind.values())
|
||||
.map(Enum::name)
|
||||
.toList())
|
||||
.readOnly())
|
||||
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
|
||||
.p("enabled", "Enabled", PropType.BOOL, n -> n.enabled, (n, v) -> n.enabled = v)
|
||||
.group(k.label());
|
||||
for (BrushKind.Param p : k.params()) {
|
||||
l.p(p.name(), p.label(), p.type(), n -> n.values.get(p.name()), (n, v) -> n.values.put(p.name(), v), b -> {
|
||||
if (p.nullable()) {
|
||||
b.nullable();
|
||||
}
|
||||
if (p.type() == PropType.CHOICE) {
|
||||
b.choices(p.choices());
|
||||
}
|
||||
if (p.type() == PropType.REF) {
|
||||
b.ref(NodeKind.valueOf(p.choices().getFirst()));
|
||||
}
|
||||
return b;
|
||||
});
|
||||
}
|
||||
return l.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return (name.isEmpty() ? brushKind.label() : name) + " (" + id() + ")";
|
||||
}
|
||||
|
||||
public BrushKind brushKind() {
|
||||
return brushKind;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** False only for kinds with a "visible" parameter that is switched off (invisible collision). */
|
||||
public boolean visible() {
|
||||
return !(values.get("visible") instanceof Boolean v) || v;
|
||||
}
|
||||
|
||||
public double d(String p) {
|
||||
Object v = values.get(p);
|
||||
return v == null ? Double.NaN : ((Number) v).doubleValue();
|
||||
}
|
||||
|
||||
public Double dOrNull(String p) {
|
||||
Object v = values.get(p);
|
||||
return v == null ? null : ((Number) v).doubleValue();
|
||||
}
|
||||
|
||||
public int i(String p) {
|
||||
return ((Number) values.get(p)).intValue();
|
||||
}
|
||||
|
||||
public boolean b(String p) {
|
||||
return (Boolean) values.get(p);
|
||||
}
|
||||
|
||||
public String s(String p) {
|
||||
Object v = values.get(p);
|
||||
return v == null ? "" : (String) v;
|
||||
}
|
||||
|
||||
public double[] a(String p) {
|
||||
return (double[]) values.get(p);
|
||||
}
|
||||
|
||||
public Node ref(String p) {
|
||||
return (Node) values.get(p);
|
||||
}
|
||||
|
||||
/** Parameter values after a translation by (dx, dy, dz) (for a move command). */
|
||||
public Map<String, Object> translated(double dx, double dy, double dz) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (BrushKind.Param p : brushKind.params()) {
|
||||
Object v = values.get(p.name());
|
||||
if (v == null) {
|
||||
continue;
|
||||
}
|
||||
Object nv =
|
||||
switch (p.role()) {
|
||||
case X -> (Double) v + dx;
|
||||
case Y -> (Double) v + dy;
|
||||
case Z -> (Double) v + dz;
|
||||
case XZ_LIST -> shift((double[]) v, new double[] {dx, dz});
|
||||
case XYZ_LIST -> shift((double[]) v, new double[] {dx, dy, dz});
|
||||
case XZ_RECT -> shift((double[]) v, new double[] {dx, dz});
|
||||
case NONE -> null;
|
||||
};
|
||||
if (nv != null) {
|
||||
out.put(p.name(), nv);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double[] shift(double[] a, double[] d) {
|
||||
double[] r = a.clone();
|
||||
for (int i = 0; i < r.length; i++) {
|
||||
r[i] += d[i % d.length];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Axis-aligned world bounds {x0, y0, z0, x1, y1, z1} (approximate): {@link #localBounds()} turned
|
||||
* by the brush rotation about {@link #pivot()}.
|
||||
*/
|
||||
public double[] bounds() {
|
||||
double[] lb = localBounds();
|
||||
if (!hasRotation() || !Double.isFinite(lb[0])) {
|
||||
return lb;
|
||||
}
|
||||
double[] m = rotationMatrix(rotation());
|
||||
double[] pv = pivot();
|
||||
double[] b = Bounds.empty();
|
||||
for (int c = 0; c < 8; c++) {
|
||||
double[] p = {lb[(c & 1) != 0 ? 3 : 0], lb[(c & 2) != 0 ? 4 : 1], lb[(c & 4) != 0 ? 5 : 2]};
|
||||
double[] w = rotate(m, pv, p);
|
||||
for (int k = 0; k < 3; k++) {
|
||||
Bounds.grow(b, k, w[k]);
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Bounds before the rotation, including the sizes of platforms, pillars, trees, meshes, … */
|
||||
public double[] localBounds() {
|
||||
double[] b = roleBounds();
|
||||
if (!Double.isFinite(b[0])) {
|
||||
return b;
|
||||
}
|
||||
switch (brushKind) {
|
||||
case PLATFORM -> {
|
||||
Bounds.grow(b, 0, d("x") + d("w"));
|
||||
Bounds.grow(b, 2, d("z") + d("d"));
|
||||
Bounds.grow(b, 1, d("y") - d("thick"));
|
||||
}
|
||||
case PILLAR -> square(b, d("size") / 2);
|
||||
case CYLINDER -> square(b, d("radius"));
|
||||
case TREE -> {
|
||||
square(b, d("crown") / 2);
|
||||
Bounds.grow(b, 1, d("y") + d("height") + d("crown") * 1.1);
|
||||
}
|
||||
case FENCE -> Bounds.grow(b, 1, d("y") + d("height"));
|
||||
case RAMP, STAIRS -> {
|
||||
Double base = dOrNull("base");
|
||||
Bounds.grow(b, 1, base != null ? base : b[1] - 0.5);
|
||||
}
|
||||
case MESH -> {
|
||||
Node a = ref("asset");
|
||||
if (a instanceof MeshAsset ma && ma.mesh() != null) {
|
||||
double[] mb = ma.mesh().bounds();
|
||||
double sc = d("scale");
|
||||
double[] st = values.get("stretch") instanceof double[] s ? s : new double[] {1, 1, 1};
|
||||
double ang = Math.toRadians(d("rotY"));
|
||||
double ca = Math.cos(ang);
|
||||
double sa = Math.sin(ang);
|
||||
for (int c = 0; c < 8; c++) {
|
||||
double x = (c & 1) != 0 ? mb[3] : mb[0];
|
||||
double y = (c & 2) != 0 ? mb[4] : mb[1];
|
||||
double z = (c & 4) != 0 ? mb[5] : mb[2];
|
||||
x *= sc * st[0];
|
||||
y *= sc * st[1];
|
||||
z *= sc * st[2];
|
||||
Bounds.grow(b, 0, d("x") + x * ca + z * sa);
|
||||
Bounds.grow(b, 1, d("y") + y);
|
||||
Bounds.grow(b, 2, d("z") - x * sa + z * ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
private void square(double[] b, double h) {
|
||||
double cx = (b[0] + b[3]) / 2;
|
||||
double cz = (b[2] + b[5]) / 2;
|
||||
Bounds.grow(b, 0, cx - h);
|
||||
Bounds.grow(b, 0, cx + h);
|
||||
Bounds.grow(b, 2, cz - h);
|
||||
Bounds.grow(b, 2, cz + h);
|
||||
}
|
||||
|
||||
/** Point the rotation turns about: the mesh origin for meshes, else the centre of the local bounds. */
|
||||
public double[] pivot() {
|
||||
if (brushKind == BrushKind.MESH) {
|
||||
return new double[] {d("x"), d("y"), d("z")};
|
||||
}
|
||||
double[] b = localBounds();
|
||||
return new double[] {(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2};
|
||||
}
|
||||
|
||||
/** Rotation {x, y, z} in degrees (game space, applied as x·y·z like JOML rotateXYZ), zeros if none. */
|
||||
public double[] rotation() {
|
||||
return values.get("rotation") instanceof double[] r && r.length == 3 ? r : new double[3];
|
||||
}
|
||||
|
||||
public boolean hasRotation() {
|
||||
double[] r = rotation();
|
||||
return r[0] % 360 != 0 || r[1] % 360 != 0 || r[2] % 360 != 0;
|
||||
}
|
||||
|
||||
/** Row-major 3×3 matrix Rx·Ry·Rz of rotation angles in degrees (game space, right-handed). */
|
||||
public static double[] rotationMatrix(double[] deg) {
|
||||
double ax = Math.toRadians(deg[0]);
|
||||
double ay = Math.toRadians(deg[1]);
|
||||
double az = Math.toRadians(deg[2]);
|
||||
double cx = Math.cos(ax), sx = Math.sin(ax);
|
||||
double cy = Math.cos(ay), sy = Math.sin(ay);
|
||||
double cz = Math.cos(az), sz = Math.sin(az);
|
||||
return new double[] {
|
||||
cy * cz,
|
||||
-cy * sz,
|
||||
sy,
|
||||
sx * sy * cz + cx * sz,
|
||||
-sx * sy * sz + cx * cz,
|
||||
-sx * cy,
|
||||
-cx * sy * cz + sx * sz,
|
||||
cx * sy * sz + sx * cz,
|
||||
cx * cy
|
||||
};
|
||||
}
|
||||
|
||||
/** m·(p − pivot) + pivot. */
|
||||
public static double[] rotate(double[] m, double[] pivot, double[] p) {
|
||||
double x = p[0] - pivot[0], y = p[1] - pivot[1], z = p[2] - pivot[2];
|
||||
return new double[] {
|
||||
pivot[0] + m[0] * x + m[1] * y + m[2] * z,
|
||||
pivot[1] + m[3] * x + m[4] * y + m[5] * z,
|
||||
pivot[2] + m[6] * x + m[7] * y + m[8] * z
|
||||
};
|
||||
}
|
||||
|
||||
private double[] roleBounds() {
|
||||
double[] b = Bounds.empty();
|
||||
for (BrushKind.Param p : brushKind.params()) {
|
||||
Object v = values.get(p.name());
|
||||
if (v == null) {
|
||||
continue;
|
||||
}
|
||||
switch (p.role()) {
|
||||
case X -> Bounds.grow(b, 0, (Double) v);
|
||||
case Y -> Bounds.grow(b, 1, (Double) v);
|
||||
case Z -> Bounds.grow(b, 2, (Double) v);
|
||||
case XZ_LIST, XZ_RECT -> {
|
||||
double[] a = (double[]) v;
|
||||
for (int i = 0; i + 1 < a.length; i += 2) {
|
||||
Bounds.grow(b, 0, a[i]);
|
||||
Bounds.grow(b, 2, a[i + 1]);
|
||||
}
|
||||
}
|
||||
case XYZ_LIST -> {
|
||||
double[] a = (double[]) v;
|
||||
for (int i = 0; i + 2 < a.length; i += 3) {
|
||||
Bounds.grow(b, 0, a[i]);
|
||||
Bounds.grow(b, 1, a[i + 1]);
|
||||
Bounds.grow(b, 2, a[i + 2]);
|
||||
}
|
||||
}
|
||||
case NONE -> {}
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Brush (primitive) types and their parameters. Brushes are converted to tiles at build time by
|
||||
* {@code levelbuild.LevelBuilder} (same primitives as levels/testwelt.py). Coordinates are game
|
||||
* space (x east, y up, z = grid row, positive), 1 unit = 1 cell. Textures: a name of {@code
|
||||
* LevelBuilder.TEX} (village names), "#index" (texture table index), a flat colour name of {@code
|
||||
* LevelBuilder.FLAT} or "rgbRRGGBB"; "" = leave that part out.
|
||||
*/
|
||||
public enum BrushKind {
|
||||
FLOOR("Floor", "Flat horizontal surface split into 1×1 tiles"),
|
||||
BOX("Box", "Axis-aligned block with top, sides and optional bottom"),
|
||||
PLATFORM("Platform", "Floating platform (box with edge and bottom textures)"),
|
||||
PILLAR("Pillar", "Square pillar around a centre point"),
|
||||
CYLINDER("Cylinder", "Round platform or column: n-sided prism around a centre point"),
|
||||
RAMP("Ramp", "Slope rising in one direction, sides closed to the base"),
|
||||
STAIRS("Stairs", "Steps rising in one direction (walkable = invisible slope as collision)"),
|
||||
FENCE("Fence", "Posts and rail along a polyline"),
|
||||
TREE("Tree", "Block tree: trunk and two crown cubes"),
|
||||
SEA("Sea / water", "Water surface without collision, optionally with a death plane"),
|
||||
KILL_PLANE("Death plane", "Invisible no-floor plane (material 30) in all cells not covered by floor"),
|
||||
QUAD("Quad", "Single quad or triangle (corners seen from the front)"),
|
||||
COLLIDER("Collider", "Invisible collision surface"),
|
||||
MESH("Mesh", "Imported mesh (OBJ/glTF) split into tiles, with collision");
|
||||
|
||||
/** Coordinate role of a parameter (for moving brushes). */
|
||||
public enum Role {
|
||||
NONE,
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
/** double[] of (x, z) pairs. */
|
||||
XZ_LIST,
|
||||
/** double[] of (x, y, z) triples. */
|
||||
XYZ_LIST,
|
||||
/** double[4] {x0, z0, x1, z1}. */
|
||||
XZ_RECT
|
||||
}
|
||||
|
||||
public record Param(String name, String label, PropType type, Object def, Role role, List<String> choices) {
|
||||
public boolean nullable() {
|
||||
return def == null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final List<String> DIRECTIONS = List.of("+x", "-x", "+z", "-z");
|
||||
public static final List<String> KILL_MODES = List.of("none", "whole", "area");
|
||||
|
||||
/** Face classes of the generic face parameters: top, bottom, north (z0), east (x1), south (z1), west (x0). */
|
||||
public static final String FACES = "tbnesw";
|
||||
|
||||
/** Kinds with the generic parameters rotation, visible, collFaces, faceMats (cell-based kinds have none). */
|
||||
public boolean transformable() {
|
||||
return this != SEA && this != KILL_PLANE && this != COLLIDER;
|
||||
}
|
||||
|
||||
private final String label;
|
||||
private final String description;
|
||||
private List<Param> params;
|
||||
|
||||
BrushKind(String label, String description) {
|
||||
this.label = label;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public synchronized List<Param> params() {
|
||||
if (params == null) {
|
||||
params = List.copyOf(define(this));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
public Param param(String name) {
|
||||
for (Param p : params()) {
|
||||
if (p.name.equals(name)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException(this + " has no parameter " + name);
|
||||
}
|
||||
|
||||
private static final double[] WHITE = {1, 1, 1};
|
||||
|
||||
private static List<Param> define(BrushKind k) {
|
||||
P p = new P();
|
||||
defineKind(k, p);
|
||||
if (k.transformable()) {
|
||||
p.a("rotation", "Rotation (x, y, z °)", new double[] {0, 0, 0}, Role.NONE);
|
||||
if (k == MESH) {
|
||||
p.a("stretch", "Stretch (x, y, z)", new double[] {1, 1, 1}, Role.NONE);
|
||||
}
|
||||
if (k != QUAD) {
|
||||
p.b("visible", "Visible (off = invisible collision)", true);
|
||||
}
|
||||
p.s("collFaces", "Faces with collision (t b n e s w)", FACES);
|
||||
p.ia("faceMats", "Material per face (t b n e s w, −1 = brush)", new int[] {-1, -1, -1, -1, -1, -1});
|
||||
}
|
||||
return p.l;
|
||||
}
|
||||
|
||||
private static void defineKind(BrushKind k, P p) {
|
||||
switch (k) {
|
||||
case FLOOR -> p.rect()
|
||||
.d("y", "Height", 0.0, Role.Y)
|
||||
.s("tex", "Texture", "grass")
|
||||
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
|
||||
.d("step", "Tile size", 1.0, Role.NONE)
|
||||
.b("coll", "Collision", true)
|
||||
.i("mat", "Collision material", 0)
|
||||
.tint();
|
||||
case BOX -> p.rect()
|
||||
.d("y0", "Bottom", 0.0, Role.Y)
|
||||
.d("y1", "Top", 1.0, Role.Y)
|
||||
.s("top", "Top texture", "grass")
|
||||
.s("side", "Side texture", "earth")
|
||||
.s("sideTop", "Top row of the sides", "")
|
||||
.s("bottom", "Bottom texture", "")
|
||||
.s("sides", "Sides (n, e, s, w)", "nesw")
|
||||
.b("coll", "Collision", true)
|
||||
.b("sideColl", "Side collision", true)
|
||||
.i("mat", "Collision material", 0)
|
||||
.b("solid", "Casts shadows", true)
|
||||
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
|
||||
.d("step", "Tile size", 1.0, Role.NONE)
|
||||
.tint();
|
||||
case PLATFORM -> p.d("x", "X", 0.0, Role.X)
|
||||
.d("z", "Z", 0.0, Role.Z)
|
||||
.d("w", "Width (x)", 2.0, Role.NONE)
|
||||
.d("d", "Depth (z)", 2.0, Role.NONE)
|
||||
.d("y", "Top", 1.0, Role.Y)
|
||||
.d("thick", "Thickness", 0.5, Role.NONE)
|
||||
.s("top", "Top texture", "grass")
|
||||
.s("side", "Side texture", "earth")
|
||||
.s("sideTop", "Top row of the sides", "grass_edge")
|
||||
.s("bottom", "Bottom texture", "earth")
|
||||
.i("mat", "Collision material", 0)
|
||||
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
|
||||
.tint();
|
||||
case CYLINDER -> p.d("x", "X (centre)", 0.0, Role.X)
|
||||
.d("z", "Z (centre)", 0.0, Role.Z)
|
||||
.d("y0", "Bottom", 0.0, Role.Y)
|
||||
.d("y1", "Top", 1.0, Role.Y)
|
||||
.d("radius", "Radius", 1.5, Role.NONE)
|
||||
.i("segments", "Segments (3-64)", 16)
|
||||
.s("top", "Top texture", "stone")
|
||||
.s("side", "Side texture", "stone2")
|
||||
.s("sideTop", "Top row of the sides", "")
|
||||
.s("bottom", "Bottom texture", "")
|
||||
.b("coll", "Collision", true)
|
||||
.b("sideColl", "Side collision", true)
|
||||
.i("mat", "Collision material", 0)
|
||||
.tint();
|
||||
case PILLAR -> p.d("x", "X (centre)", 0.0, Role.X)
|
||||
.d("z", "Z (centre)", 0.0, Role.Z)
|
||||
.d("y0", "Bottom", 0.0, Role.Y)
|
||||
.d("y1", "Top", 1.5, Role.Y)
|
||||
.d("size", "Size", 0.5, Role.NONE)
|
||||
.s("side", "Side texture", "stone")
|
||||
.s("top", "Top texture", "stone")
|
||||
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
|
||||
.tint();
|
||||
case RAMP -> p.rect()
|
||||
.d("ya", "Low end height", 0.0, Role.Y)
|
||||
.d("yb", "High end height", 1.0, Role.Y)
|
||||
.c("direction", "Rises towards", "+x", DIRECTIONS)
|
||||
.s("top", "Top texture", "sand")
|
||||
.s("side", "Side texture", "earth")
|
||||
.n("base", "Base height (empty = low end − 0.5)", Role.Y)
|
||||
.b("coll", "Collision", true)
|
||||
.d("step", "Tile size", 1.0, Role.NONE)
|
||||
.tint();
|
||||
case STAIRS -> p.rect()
|
||||
.d("y0", "Start height (floor in front)", 0.0, Role.Y)
|
||||
.d("y1", "Top height", 1.0, Role.Y)
|
||||
.i("steps", "Steps", 4)
|
||||
.c("direction", "Rises towards", "+x", DIRECTIONS)
|
||||
.s("top", "Top texture", "stone")
|
||||
.s("side", "Side texture", "stone2")
|
||||
.n("base", "Base height (empty = bottom − 0.5)", Role.Y)
|
||||
.b("walkable", "Walkable (invisible slope)", true)
|
||||
.tint();
|
||||
case FENCE -> p.a("points", "Points (x, z pairs)", new double[] {0, 0, 4, 0}, Role.XZ_LIST)
|
||||
.d("y", "Ground height", 0.0, Role.Y)
|
||||
.d("height", "Height", 0.45, Role.NONE)
|
||||
.d("thick", "Post size", 0.07, Role.NONE)
|
||||
.s("post", "Post texture", "wood_dark")
|
||||
.s("rail", "Rail texture", "wood_grain")
|
||||
.d("postsEvery", "Post spacing", 1.0, Role.NONE);
|
||||
case TREE -> p.d("x", "X", 0.0, Role.X)
|
||||
.d("z", "Z", 0.0, Role.Z)
|
||||
.d("y", "Ground height", 0.0, Role.Y)
|
||||
.d("height", "Trunk height", 1.6, Role.NONE)
|
||||
.d("crown", "Crown size", 1.1, Role.NONE)
|
||||
.s("trunk", "Trunk texture", "bark")
|
||||
.s("leaves", "Leaves texture", "grass_dark")
|
||||
.d("trunkSize", "Trunk size (0 = auto)", 0.0, Role.NONE);
|
||||
case SEA -> p.rect()
|
||||
.d("y", "Water height", -1.5, Role.Y)
|
||||
.s("tex", "Texture", "sea")
|
||||
.c("kill", "Death plane", "whole", KILL_MODES)
|
||||
.a("killArea", "Death plane area (x0, z0, x1, z1)", new double[] {0, 0, 0, 0}, Role.XZ_RECT)
|
||||
.d("step", "Tile size", 4.0, Role.NONE);
|
||||
case KILL_PLANE -> p.rect().d("y", "Height", -1.5, Role.Y);
|
||||
case QUAD -> p.a(
|
||||
"points",
|
||||
"Corners (x, y, z) × 3 or 4",
|
||||
new double[] {0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1},
|
||||
Role.XYZ_LIST)
|
||||
.s("tex", "Texture", "stone")
|
||||
.b("coll", "Collision", true)
|
||||
.i("mat", "Collision material", 0)
|
||||
.i("texRot", "Texture rotation (0-3)", 0)
|
||||
.b("doubleSided", "Double-sided", false)
|
||||
.b("shade", "Lit (vertex colours)", true)
|
||||
.b("render", "Visible", true)
|
||||
.tint();
|
||||
case COLLIDER -> p.a(
|
||||
"points",
|
||||
"Corners (x, y, z) × 3 or 4",
|
||||
new double[] {0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1},
|
||||
Role.XYZ_LIST)
|
||||
.i("mat", "Collision material", 0)
|
||||
.a("normal", "Front side normal", new double[] {0, 1, 0}, Role.NONE);
|
||||
case MESH -> p.r("asset", "Mesh asset", NodeKind.MESH_ASSET)
|
||||
.d("x", "X", 0.0, Role.X)
|
||||
.d("y", "Y", 0.0, Role.Y)
|
||||
.d("z", "Z", 0.0, Role.Z)
|
||||
.d("rotY", "Rotation (°)", 0.0, Role.NONE)
|
||||
.d("scale", "Scale", 1.0, Role.NONE)
|
||||
.b("coll", "Collision", true)
|
||||
.i("mat", "Collision material", 0)
|
||||
.b("doubleSided", "Double-sided", false)
|
||||
.b("shade", "Lit (vertex colours)", true)
|
||||
.i("texSize", "Texture size on the page (16-256)", 128)
|
||||
.i("uvSteps", "UV rectangle precision (steps per texture)", 8)
|
||||
.tint();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class P {
|
||||
final List<Param> l = new ArrayList<>();
|
||||
|
||||
P d(String n, String lb, double def, Role r) {
|
||||
l.add(new Param(n, lb, PropType.DOUBLE, def, r, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P n(String n, String lb, Role r) {
|
||||
l.add(new Param(n, lb, PropType.DOUBLE, null, r, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P i(String n, String lb, int def) {
|
||||
l.add(new Param(n, lb, PropType.INT, def, Role.NONE, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P b(String n, String lb, boolean def) {
|
||||
l.add(new Param(n, lb, PropType.BOOL, def, Role.NONE, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P s(String n, String lb, String def) {
|
||||
l.add(new Param(n, lb, PropType.STRING, def, Role.NONE, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P c(String n, String lb, String def, List<String> ch) {
|
||||
l.add(new Param(n, lb, PropType.CHOICE, def, Role.NONE, ch));
|
||||
return this;
|
||||
}
|
||||
|
||||
P a(String n, String lb, double[] def, Role r) {
|
||||
l.add(new Param(n, lb, PropType.DOUBLE_ARRAY, def, r, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P ia(String n, String lb, int[] def) {
|
||||
l.add(new Param(n, lb, PropType.INT_ARRAY, def, Role.NONE, List.of()));
|
||||
return this;
|
||||
}
|
||||
|
||||
P r(String n, String lb, NodeKind k) {
|
||||
l.add(new Param(n, lb, PropType.REF, null, Role.NONE, List.of(k.name())));
|
||||
return this;
|
||||
}
|
||||
|
||||
P rect() {
|
||||
return d("x0", "X0", 0.0, Role.X)
|
||||
.d("z0", "Z0", 0.0, Role.Z)
|
||||
.d("x1", "X1", 1.0, Role.X)
|
||||
.d("z1", "Z1", 1.0, Role.Z);
|
||||
}
|
||||
|
||||
P tint() {
|
||||
return a("tint", "Tint (r, g, b)", WHITE.clone(), Role.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Environment / teleport zone (MAP 90-byte record, 0x5c bytes in memory; FUN_0054d560,
|
||||
* FUN_0054de00, FUN_0041e1c0). A zone is entered through its trigger cell; it moves the player to
|
||||
* the target position (teleport/respawn) and switches fog, colours and music. The entry with
|
||||
* target (1, 1, 1) is the level-wide default. Fog distances are 20.12 fixed point (4096 = 1 cell):
|
||||
* "low" = detail Minimum, "high" = detail Maximum (if high start ≤ low start the engine uses 1.5 ×
|
||||
* the low pair). Field names with "unk" are not understood.
|
||||
*/
|
||||
public final class EnvZone extends Node {
|
||||
/** Field names in file order; u16 fields: unk14, unk21, unk22. */
|
||||
public static final String[] FIELDS = {
|
||||
"cellX",
|
||||
"cellY",
|
||||
"cellZ",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"unk6",
|
||||
"angleFlags",
|
||||
"flags",
|
||||
"unk9",
|
||||
"stpcOffset",
|
||||
"skyYaw",
|
||||
"unk12",
|
||||
"fogMode",
|
||||
"unk14",
|
||||
"fogNearLow",
|
||||
"fogFarLow",
|
||||
"fogNearHigh",
|
||||
"fogFarHigh",
|
||||
"ambientColor",
|
||||
"fogColor",
|
||||
"unk21",
|
||||
"unk22",
|
||||
"music"
|
||||
};
|
||||
|
||||
private static final String[] LABELS = {
|
||||
"Trigger cell X",
|
||||
"Trigger cell Y",
|
||||
"Trigger cell Z",
|
||||
"Target X",
|
||||
"Target Y",
|
||||
"Target Z",
|
||||
"Field +18",
|
||||
"Facing (bits 4-11) + y compare (bits 0-1)",
|
||||
"Flags (bit 8 = start zone)",
|
||||
"Field +24",
|
||||
"STPC offset (0 = none)",
|
||||
"Sky yaw offset",
|
||||
"Field +30",
|
||||
"Fog mode (3 = vertex fog, 2 = other)",
|
||||
"Field +38 (u16)",
|
||||
"Fog start (detail Minimum)",
|
||||
"Fog end (detail Minimum)",
|
||||
"Fog start (detail Maximum)",
|
||||
"Fog end (detail Maximum)",
|
||||
"Ambient colour",
|
||||
"Fog / backdrop colour",
|
||||
"Field +52 (u16)",
|
||||
"Field +54 (u16)",
|
||||
"Music id"
|
||||
};
|
||||
|
||||
private static final PropType[] TYPES = {
|
||||
PropType.INT,
|
||||
PropType.INT,
|
||||
PropType.INT,
|
||||
PropType.FIXED,
|
||||
PropType.FIXED,
|
||||
PropType.FIXED,
|
||||
PropType.INT,
|
||||
PropType.FLAGS,
|
||||
PropType.FLAGS,
|
||||
PropType.INT,
|
||||
PropType.FLAGS,
|
||||
PropType.INT,
|
||||
PropType.INT,
|
||||
PropType.INT,
|
||||
PropType.INT,
|
||||
PropType.FIXED,
|
||||
PropType.FIXED,
|
||||
PropType.FIXED,
|
||||
PropType.FIXED,
|
||||
PropType.COLOR,
|
||||
PropType.COLOR,
|
||||
PropType.INT,
|
||||
PropType.INT,
|
||||
PropType.INT
|
||||
};
|
||||
|
||||
private static final String[] GROUPS = {
|
||||
"Trigger", "Trigger", "Trigger", "Target", "Target", "Target", "Raw", "Target", "Zone", "Raw", "Zone", "Zone",
|
||||
"Raw", "Fog", "Raw", "Fog", "Fog", "Fog", "Fog", "Fog", "Fog", "Raw", "Raw", "Music"
|
||||
};
|
||||
|
||||
final int[] f = new int[FIELDS.length];
|
||||
|
||||
private static final List<Prop> PROPS = defineProps();
|
||||
|
||||
private static List<Prop> defineProps() {
|
||||
Props<EnvZone> l = new Props<>();
|
||||
for (int i = 0; i < FIELDS.length; i++) {
|
||||
int k = i;
|
||||
l.group(GROUPS[i]).p(FIELDS[i], LABELS[i], TYPES[i], n -> n.f[k], (n, v) -> n.f[k] = v);
|
||||
}
|
||||
return l.build();
|
||||
}
|
||||
|
||||
public EnvZone(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.ENV_ZONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return isDefault() ? "default zone" : "zone " + f[0] + "," + f[2];
|
||||
}
|
||||
|
||||
/** All 24 fields in file order (copy). */
|
||||
public int[] fields() {
|
||||
return f.clone();
|
||||
}
|
||||
|
||||
public int field(int i) {
|
||||
return f[i];
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
return f[3] == 1 && f[4] == 1 && f[5] == 1;
|
||||
}
|
||||
|
||||
public int cellX() {
|
||||
return f[0];
|
||||
}
|
||||
|
||||
public int cellY() {
|
||||
return f[1];
|
||||
}
|
||||
|
||||
public int cellZ() {
|
||||
return f[2];
|
||||
}
|
||||
|
||||
public double targetX() {
|
||||
return f[3] / 4096.0;
|
||||
}
|
||||
|
||||
public double targetY() {
|
||||
return f[4] / 4096.0;
|
||||
}
|
||||
|
||||
public double targetZ() {
|
||||
return f[5] / 4096.0;
|
||||
}
|
||||
|
||||
/** Facing angle at the target, 4096 = 360° (bits 4..11 of the angle field, as 1/256 turns). */
|
||||
public int facing() {
|
||||
return ((f[7] >> 4) & 0xff) << 4;
|
||||
}
|
||||
|
||||
public int fogMode() {
|
||||
return f[13];
|
||||
}
|
||||
|
||||
public double fogNearLow() {
|
||||
return f[15] / 4096.0;
|
||||
}
|
||||
|
||||
public double fogFarLow() {
|
||||
return f[16] / 4096.0;
|
||||
}
|
||||
|
||||
public double fogNearHigh() {
|
||||
return f[17] / 4096.0;
|
||||
}
|
||||
|
||||
public double fogFarHigh() {
|
||||
return f[18] / 4096.0;
|
||||
}
|
||||
|
||||
public int ambientColor() {
|
||||
return f[19];
|
||||
}
|
||||
|
||||
public int fogColor() {
|
||||
return f[20];
|
||||
}
|
||||
|
||||
public int music() {
|
||||
return f[23];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Level-wide settings (singleton node "settings:0"): name, grid, strat pool, project metadata, the
|
||||
* lighting used to bake vertex colours of brushes, and verbatim MAP trailer data.
|
||||
*/
|
||||
public final class LevelSettings extends Node {
|
||||
/** Project kinds. */
|
||||
public static final String EDIT = "edit";
|
||||
|
||||
public static final String NEW = "new";
|
||||
|
||||
String levelName = "";
|
||||
String kindName = EDIT;
|
||||
String base = "";
|
||||
String author = "";
|
||||
String description = "";
|
||||
int targetChapter;
|
||||
int wfpc;
|
||||
int width;
|
||||
int height;
|
||||
int poolExtra;
|
||||
int poolReserve;
|
||||
int[] paramPool = new int[0];
|
||||
byte[] mapTail = new byte[0];
|
||||
byte[] nameChunkPrefix = new byte[4];
|
||||
byte[] textPalettes = new byte[4];
|
||||
byte[] textTail = new byte[0];
|
||||
byte[] lghtTail = new byte[0];
|
||||
long seed = 1;
|
||||
double sunAzimuth = 215;
|
||||
double sunElevation = 50;
|
||||
double sunStrength = 0.62;
|
||||
double[] sunColor = {1.0, 0.93, 0.80};
|
||||
double[] sky = {0.50, 0.55, 0.66};
|
||||
double[] ground = {0.30, 0.28, 0.24};
|
||||
double ao = 0.55;
|
||||
double aoRadius = 1.3;
|
||||
double shadow = 0.35;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<LevelSettings>()
|
||||
.group("Level")
|
||||
.str("name", "Level name", n -> n.levelName, (n, v) -> n.levelName = v)
|
||||
.p("kind", "Project kind", PropType.CHOICE, n -> n.kindName, (n, v) -> n.kindName = v, b -> b.choices(
|
||||
List.of(EDIT, NEW))
|
||||
.readOnly()
|
||||
.desc("edit = changes to an original level, new = own level on a TestLevel chapter"))
|
||||
.p("base", "Base level", PropType.STRING, n -> n.base, (n, v) -> n.base = v, b -> b.readOnly()
|
||||
.desc("original level the project builds on (object code, textures, sound)"))
|
||||
.integer("targetChapter", "TestLevel chapter", n -> n.targetChapter, (n, v) -> n.targetChapter = v)
|
||||
.str("author", "Author", n -> n.author, (n, v) -> n.author = v)
|
||||
.str("description", "Description", n -> n.description, (n, v) -> n.description = v)
|
||||
.group("Grid")
|
||||
.p(
|
||||
"wfpc",
|
||||
"Feature flags (WFPC)",
|
||||
PropType.FLAGS,
|
||||
n -> n.wfpc,
|
||||
(n, v) -> n.wfpc = v,
|
||||
Prop.Builder::readOnly)
|
||||
.p("width", "Grid width", PropType.INT, n -> n.width, (n, v) -> n.width = v, Prop.Builder::readOnly)
|
||||
.p("height", "Grid height", PropType.INT, n -> n.height, (n, v) -> n.height = v, Prop.Builder::readOnly)
|
||||
.group("Objects")
|
||||
.p(
|
||||
"poolExtra",
|
||||
"Strat pool extra",
|
||||
PropType.INT,
|
||||
n -> n.poolExtra,
|
||||
(n, v) -> n.poolExtra = v,
|
||||
b -> b.desc("strat slots = poolExtra + 30; the build uses max(poolExtra, objects + reserve)"))
|
||||
.integer("poolReserve", "Strat pool reserve", n -> n.poolReserve, (n, v) -> n.poolReserve = v)
|
||||
.group("Lighting")
|
||||
.integer("seed", "Texture rotation seed", n -> (int) n.seed, (n, v) -> n.seed = v)
|
||||
.dbl("sunAzimuth", "Sun azimuth (°)", n -> n.sunAzimuth, (n, v) -> n.sunAzimuth = v)
|
||||
.dbl("sunElevation", "Sun elevation (°)", n -> n.sunElevation, (n, v) -> n.sunElevation = v)
|
||||
.dbl("sunStrength", "Sun strength", n -> n.sunStrength, (n, v) -> n.sunStrength = v)
|
||||
.doubles("sunColor", "Sun colour (r, g, b)", n -> n.sunColor, (n, v) -> n.sunColor = v)
|
||||
.doubles("sky", "Sky light (r, g, b)", n -> n.sky, (n, v) -> n.sky = v)
|
||||
.doubles("ground", "Ground light (r, g, b)", n -> n.ground, (n, v) -> n.ground = v)
|
||||
.dbl("ao", "Ambient occlusion", n -> n.ao, (n, v) -> n.ao = v)
|
||||
.dbl("aoRadius", "AO radius", n -> n.aoRadius, (n, v) -> n.aoRadius = v)
|
||||
.dbl("shadow", "Shadow", n -> n.shadow, (n, v) -> n.shadow = v)
|
||||
.group("Raw")
|
||||
.p(
|
||||
"paramPool",
|
||||
"Original parameter pool",
|
||||
PropType.INT_ARRAY,
|
||||
n -> n.paramPool,
|
||||
(n, v) -> n.paramPool = v,
|
||||
b -> b.readOnly()
|
||||
.desc("strats whose parameters are unchanged keep their pool index; changed ones are"
|
||||
+ " appended"))
|
||||
.p(
|
||||
"mapTail",
|
||||
"MAP data after the strats (paths, u32, u16, rest)",
|
||||
PropType.BYTES,
|
||||
n -> n.mapTail,
|
||||
(n, v) -> n.mapTail = v,
|
||||
Prop.Builder::readOnly)
|
||||
.p(
|
||||
"nameChunkPrefix",
|
||||
"NAME chunk prefix",
|
||||
PropType.BYTES,
|
||||
n -> n.nameChunkPrefix,
|
||||
(n, v) -> n.nameChunkPrefix = v,
|
||||
Prop.Builder::readOnly)
|
||||
.p(
|
||||
"textPalettes",
|
||||
"TEXT palettes",
|
||||
PropType.BYTES,
|
||||
n -> n.textPalettes,
|
||||
(n, v) -> n.textPalettes = v,
|
||||
Prop.Builder::readOnly)
|
||||
.p(
|
||||
"textTail",
|
||||
"TEXT trailer",
|
||||
PropType.BYTES,
|
||||
n -> n.textTail,
|
||||
(n, v) -> n.textTail = v,
|
||||
Prop.Builder::readOnly)
|
||||
.p(
|
||||
"lghtTail",
|
||||
"LGHT trailer",
|
||||
PropType.BYTES,
|
||||
n -> n.lghtTail,
|
||||
(n, v) -> n.lghtTail = v,
|
||||
Prop.Builder::readOnly)
|
||||
.build();
|
||||
|
||||
public LevelSettings(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.SETTINGS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "Level settings";
|
||||
}
|
||||
|
||||
public String levelName() {
|
||||
return levelName;
|
||||
}
|
||||
|
||||
public String projectKind() {
|
||||
return kindName;
|
||||
}
|
||||
|
||||
public boolean isNewLevel() {
|
||||
return NEW.equals(kindName);
|
||||
}
|
||||
|
||||
public String base() {
|
||||
return base;
|
||||
}
|
||||
|
||||
public String author() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/** TestLevel (world 9) chapter 1..49 for new levels, 0 = not chosen. */
|
||||
public int targetChapter() {
|
||||
return targetChapter;
|
||||
}
|
||||
|
||||
public int wfpc() {
|
||||
return wfpc;
|
||||
}
|
||||
|
||||
public int width() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public int poolExtra() {
|
||||
return poolExtra;
|
||||
}
|
||||
|
||||
public int poolReserve() {
|
||||
return poolReserve;
|
||||
}
|
||||
|
||||
public int[] paramPool() {
|
||||
return paramPool;
|
||||
}
|
||||
|
||||
public byte[] mapTail() {
|
||||
return mapTail;
|
||||
}
|
||||
|
||||
public byte[] nameChunkPrefix() {
|
||||
return nameChunkPrefix;
|
||||
}
|
||||
|
||||
/** Raw palette block of TEXT (u32 n + palettes; empty in all originals). */
|
||||
public byte[] textPalettes() {
|
||||
return textPalettes;
|
||||
}
|
||||
|
||||
/** Bytes after the texture animations (none in the originals). */
|
||||
public byte[] textTail() {
|
||||
return textTail;
|
||||
}
|
||||
|
||||
public byte[] lghtTail() {
|
||||
return lghtTail;
|
||||
}
|
||||
|
||||
public long seed() {
|
||||
return seed;
|
||||
}
|
||||
|
||||
public double sunAzimuth() {
|
||||
return sunAzimuth;
|
||||
}
|
||||
|
||||
public double sunElevation() {
|
||||
return sunElevation;
|
||||
}
|
||||
|
||||
public double sunStrength() {
|
||||
return sunStrength;
|
||||
}
|
||||
|
||||
public double[] sunColor() {
|
||||
return sunColor;
|
||||
}
|
||||
|
||||
public double[] sky() {
|
||||
return sky;
|
||||
}
|
||||
|
||||
public double[] ground() {
|
||||
return ground;
|
||||
}
|
||||
|
||||
public double ao() {
|
||||
return ao;
|
||||
}
|
||||
|
||||
public double aoRadius() {
|
||||
return aoRadius;
|
||||
}
|
||||
|
||||
public double shadow() {
|
||||
return shadow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Light (LGHT chunk, FUN_0042c180; evaluated per object vertex by FUN_0041ba10, objects get the
|
||||
* directional lights plus the nearest point lights, at most 6, FUN_0041bdb0): colour r, g, b
|
||||
* (×2 / 255 in the engine, added to the zone's ambient colour); type 1 = directional (x, y, z =
|
||||
* direction towards the light), types 2 and 4 = point lights at (x, y, z) in game space cells (z =
|
||||
* grid row; the engine negates z for its render space) with full intensity up to radius {@code a}
|
||||
* and a linear falloff to 0 at radius {@code b}; {@code c} = 1 scales the light ×1.5 / ×0.5 with
|
||||
* the level's light state (DAT_005865d4 1 / 3). Type 4 is a negative light: colour −(1 + 2r/255)/2 per channel.
|
||||
* Tiles are not lit by lights (baked vertex colours). Other types carry no data and are ignored.
|
||||
*/
|
||||
public final class Light extends Node {
|
||||
public static final int DIRECTIONAL = 1;
|
||||
public static final int POINT = 2;
|
||||
public static final int POINT_BRIGHT = 4;
|
||||
|
||||
int type = DIRECTIONAL;
|
||||
int r = 128;
|
||||
int g = 128;
|
||||
int b = 128;
|
||||
double x;
|
||||
double y;
|
||||
double z;
|
||||
double a;
|
||||
double b2;
|
||||
int c;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<Light>()
|
||||
.group("Light")
|
||||
.integer("type", "Type (1 directional, 2/4 point)", n -> n.type, (n, v) -> n.type = v)
|
||||
.integer("r", "Red", n -> n.r, (n, v) -> n.r = v)
|
||||
.integer("g", "Green", n -> n.g, (n, v) -> n.g = v)
|
||||
.integer("b", "Blue", n -> n.b, (n, v) -> n.b = v)
|
||||
.group("Position / direction")
|
||||
.dbl("x", "X", n -> n.x, (n, v) -> n.x = v)
|
||||
.dbl("y", "Y", n -> n.y, (n, v) -> n.y = v)
|
||||
.dbl("z", "Z", n -> n.z, (n, v) -> n.z = v)
|
||||
.group("Range")
|
||||
.dbl("a", "Range A", n -> n.a, (n, v) -> n.a = v)
|
||||
.dbl("rangeB", "Range B", n -> n.b2, (n, v) -> n.b2 = v)
|
||||
.integer("c", "Byte C", n -> n.c, (n, v) -> n.c = v)
|
||||
.build();
|
||||
|
||||
public Light(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.LIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return (type == DIRECTIONAL
|
||||
? "directional light "
|
||||
: type == POINT || type == POINT_BRIGHT ? "point light " : "light ")
|
||||
+ id();
|
||||
}
|
||||
|
||||
public int type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public int r() {
|
||||
return r;
|
||||
}
|
||||
|
||||
public int g() {
|
||||
return g;
|
||||
}
|
||||
|
||||
public int b() {
|
||||
return b;
|
||||
}
|
||||
|
||||
public double x() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public double y() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public double z() {
|
||||
return z;
|
||||
}
|
||||
|
||||
public double a() {
|
||||
return a;
|
||||
}
|
||||
|
||||
public double rangeB() {
|
||||
return b2;
|
||||
}
|
||||
|
||||
public int c() {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Imported 3D model (OBJ + MTL or glTF 2.0) stored in the project's {@code assets/} folder. Placed
|
||||
* with {@link BrushKind#MESH} brushes. {@code file} is the main file's asset path (e.g.
|
||||
* "assets/rock/rock.glb"); referenced files (MTL, textures, .bin) live next to it. The parsed mesh
|
||||
* is cached in {@link #mesh()} ({@code editor.io.MeshImport} fills it).
|
||||
*/
|
||||
public final class MeshAsset extends Node {
|
||||
String name = "";
|
||||
String file = "";
|
||||
/** Source axis convention: "y" (glTF/OBJ default, y up) or "z" (z up, e.g. some CAD exports). */
|
||||
String upAxis = "y";
|
||||
|
||||
double unitScale = 1.0;
|
||||
transient MeshData mesh;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<MeshAsset>()
|
||||
.group("Mesh")
|
||||
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
|
||||
.p(
|
||||
"file",
|
||||
"File",
|
||||
PropType.STRING,
|
||||
n -> n.file,
|
||||
(n, v) -> {
|
||||
n.file = v;
|
||||
n.mesh = null;
|
||||
},
|
||||
Prop.Builder::readOnly)
|
||||
.p(
|
||||
"upAxis",
|
||||
"Up axis",
|
||||
PropType.CHOICE,
|
||||
n -> n.upAxis,
|
||||
(n, v) -> {
|
||||
n.upAxis = v;
|
||||
n.mesh = null;
|
||||
},
|
||||
b -> b.choices(List.of("y", "z")))
|
||||
.dbl("unitScale", "Unit scale (file units → cells)", n -> n.unitScale, (n, v) -> {
|
||||
n.unitScale = v;
|
||||
n.mesh = null;
|
||||
})
|
||||
.build();
|
||||
|
||||
public MeshAsset(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.MESH_ASSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return (name.isEmpty() ? file : name) + (mesh == null ? "" : " (" + mesh.triangleCount() + " triangles)");
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String file() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public String upAxis() {
|
||||
return upAxis;
|
||||
}
|
||||
|
||||
public double unitScale() {
|
||||
return unitScale;
|
||||
}
|
||||
|
||||
/** Parsed mesh (game space, unplaced), or {@code null} until loaded. */
|
||||
public MeshData mesh() {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
public void setMesh(MeshData m) {
|
||||
mesh = m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A triangle mesh in game space (x east, y up, z = grid row; unplaced, 1 unit = 1 cell). Triangles
|
||||
* are wound so that (p1−p0)×(p2−p0) in export space (z mirrored) points outwards, which is the
|
||||
* order {@code LevelBuilder.quad} expects.
|
||||
*
|
||||
* @param positions 3 doubles per vertex
|
||||
* @param triangles 3 vertex indices per triangle
|
||||
* @param uvs 6 doubles per triangle (u, v per corner; v = 0 at the top of the image), NaN = none
|
||||
* @param materials material index per triangle
|
||||
* @param colors per-vertex 0xRRGGBB colour or {@code null}
|
||||
*/
|
||||
public record MeshData(
|
||||
double[] positions, int[] triangles, double[] uvs, int[] materials, int[] colors, List<Material> materialList) {
|
||||
|
||||
/**
|
||||
* @param image texture as 0xAARRGGBB, {@code null} = untextured (flat colour {@code rgb})
|
||||
*/
|
||||
public record Material(String name, int rgb, int[] image, int width, int height, boolean doubleSided) {}
|
||||
|
||||
public int vertexCount() {
|
||||
return positions.length / 3;
|
||||
}
|
||||
|
||||
public int triangleCount() {
|
||||
return triangles.length / 3;
|
||||
}
|
||||
|
||||
/** {x0, y0, z0, x1, y1, z1}. */
|
||||
public double[] bounds() {
|
||||
double[] b = Bounds.empty();
|
||||
for (int i = 0; i < positions.length; i++) {
|
||||
Bounds.grow(b, i % 3, positions[i]);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Base of every scene element. Ids are stable: imported nodes get "kind:index" from their position
|
||||
* in the original WAD, nodes created in the editor "kind:nN". All persistent state is exposed as
|
||||
* {@link Prop}s.
|
||||
*/
|
||||
public abstract class Node {
|
||||
private final String id;
|
||||
Scene scene;
|
||||
|
||||
protected Node(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public final String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public abstract NodeKind kind();
|
||||
|
||||
/** Property descriptors (same list for all nodes of a class, except brushes). */
|
||||
public abstract List<Prop> props();
|
||||
|
||||
/** Short human-readable name for outliners. */
|
||||
public String label() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The scene this node is part of, or {@code null} while detached. */
|
||||
public Scene scene() {
|
||||
return scene;
|
||||
}
|
||||
|
||||
public final Prop prop(String name) {
|
||||
for (Prop p : props()) {
|
||||
if (p.name().equals(name)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException(kind() + " has no property " + name);
|
||||
}
|
||||
|
||||
public final Object get(String name) {
|
||||
return prop(name).get(this);
|
||||
}
|
||||
|
||||
/** {@link Prop#setRaw} by name: no undo, no events (import, loading, nodes not yet added). */
|
||||
public final void setRaw(String name, Object value) {
|
||||
prop(name).setRaw(this, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
/** Node types; the id prefix is used for stable node ids ("tile:12", "strat:n3"). */
|
||||
public enum NodeKind {
|
||||
SETTINGS("settings", "Level settings"),
|
||||
REGIONS("regions", "Visibility regions"),
|
||||
TILE_MODEL("model", "Tile model"),
|
||||
TILE("tile", "Tile"),
|
||||
STRAT("strat", "Object"),
|
||||
PATH_NODE("wp", "Waypoint"),
|
||||
ENV_ZONE("zone", "Zone"),
|
||||
LIGHT("light", "Light"),
|
||||
TEXTURE_PAGE("page", "Texture page"),
|
||||
TEXTURE("tex", "Texture"),
|
||||
TEXTURE_ANIM("anim", "Texture animation"),
|
||||
BRUSH("brush", "Brush"),
|
||||
MESH_ASSET("mesh", "Mesh asset"),
|
||||
RAW_CHUNK("chunk", "Raw chunk");
|
||||
|
||||
private final String prefix;
|
||||
private final String label;
|
||||
|
||||
NodeKind(String prefix, String label) {
|
||||
this.prefix = prefix;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String prefix() {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.AbstractList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.RandomAccess;
|
||||
|
||||
/**
|
||||
* Ordered node collection of a {@link Scene}. Read-only as a {@link List}; change it through the
|
||||
* scene (events) or commands. The order is the order written to the WAD.
|
||||
*/
|
||||
public final class NodeList<T extends Node> extends AbstractList<T> implements RandomAccess {
|
||||
private final NodeKind kind;
|
||||
private final List<T> items = new ArrayList<>();
|
||||
private Map<Node, Integer> index;
|
||||
|
||||
NodeList(NodeKind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public NodeKind kind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T get(int i) {
|
||||
return items.get(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
/** Position by identity (O(1) after the first call), -1 if absent. */
|
||||
@Override
|
||||
public int indexOf(Object o) {
|
||||
if (index == null) {
|
||||
index = new IdentityHashMap<>(items.size() * 2);
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
index.put(items.get(i), i);
|
||||
}
|
||||
}
|
||||
Integer i = index.get(o);
|
||||
return i == null ? -1 : i;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(Object o) {
|
||||
return indexOf(o) >= 0;
|
||||
}
|
||||
|
||||
void insert(int i, T n) {
|
||||
items.add(i, n);
|
||||
index = null;
|
||||
}
|
||||
|
||||
void append(T n) {
|
||||
items.add(n);
|
||||
if (index != null) {
|
||||
index.put(n, items.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
T delete(int i) {
|
||||
T n = items.remove(i);
|
||||
index = null;
|
||||
return n;
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
items.clear();
|
||||
index = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Waypoint / path / camera node (MAP 34-byte record). Nodes form chains through {@code next}
|
||||
* (written as the index of the next node, 0 = end of chain); strats point at the first node of
|
||||
* their chain. Uses: patrol paths, camera start (first node of the llama's chain), checkpoint
|
||||
* respawn points (one node per CheckPointPost), box areas (two nodes = corners). Position 20.12
|
||||
* fixed point in game space, angles 4096 = 360°.
|
||||
*/
|
||||
public final class PathNode extends Node {
|
||||
PathNode next;
|
||||
int nextRaw;
|
||||
int unk1;
|
||||
int rx;
|
||||
int ry;
|
||||
int rz;
|
||||
int x;
|
||||
int y;
|
||||
int z;
|
||||
int unk2;
|
||||
int unk3;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<PathNode>()
|
||||
.group("Transform")
|
||||
.fixed("x", "X", n -> n.x, (n, v) -> n.x = v)
|
||||
.fixed("y", "Y", n -> n.y, (n, v) -> n.y = v)
|
||||
.fixed("z", "Z", n -> n.z, (n, v) -> n.z = v)
|
||||
.angle("rx", "Rotation X", n -> n.rx, (n, v) -> n.rx = v)
|
||||
.angle("ry", "Rotation Y", n -> n.ry, (n, v) -> n.ry = v)
|
||||
.angle("rz", "Rotation Z", n -> n.rz, (n, v) -> n.rz = v)
|
||||
.group("Chain")
|
||||
.p("next", "Next node", PropType.REF, n -> n.next, (n, v) -> n.next = v, b -> b.ref(NodeKind.PATH_NODE)
|
||||
.nullable())
|
||||
.group("Raw")
|
||||
.integer("nextRaw", "Next index without node", n -> n.nextRaw, (n, v) -> n.nextRaw = v)
|
||||
.integer("unk1", "Field +4", n -> n.unk1, (n, v) -> n.unk1 = v)
|
||||
.integer("unk2", "Field +26", n -> n.unk2, (n, v) -> n.unk2 = v)
|
||||
.integer("unk3", "Field +30", n -> n.unk3, (n, v) -> n.unk3 = v)
|
||||
.build();
|
||||
|
||||
public PathNode(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.PATH_NODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "waypoint " + id();
|
||||
}
|
||||
|
||||
public PathNode next() {
|
||||
return next;
|
||||
}
|
||||
|
||||
public int nextRaw() {
|
||||
return nextRaw;
|
||||
}
|
||||
|
||||
public int unk1() {
|
||||
return unk1;
|
||||
}
|
||||
|
||||
public int rx() {
|
||||
return rx;
|
||||
}
|
||||
|
||||
public int ry() {
|
||||
return ry;
|
||||
}
|
||||
|
||||
public int rz() {
|
||||
return rz;
|
||||
}
|
||||
|
||||
public int x() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public int y() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public int z() {
|
||||
return z;
|
||||
}
|
||||
|
||||
public double gx() {
|
||||
return x / 4096.0;
|
||||
}
|
||||
|
||||
public double gy() {
|
||||
return y / 4096.0;
|
||||
}
|
||||
|
||||
public double gz() {
|
||||
return z / 4096.0;
|
||||
}
|
||||
|
||||
public int unk2() {
|
||||
return unk2;
|
||||
}
|
||||
|
||||
public int unk3() {
|
||||
return unk3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* A typed, named property of a node. All persistent node state is reachable through props: the
|
||||
* inspector edits them, {@link com.eliasmueller.llamamod.editor.model.cmd.SetProp} undoes them and
|
||||
* the project format stores them. Array values are shared, not copied: never mutate an array
|
||||
* returned by {@link #get}; set a new array instead.
|
||||
*/
|
||||
public final class Prop {
|
||||
private final String name;
|
||||
private final String label;
|
||||
private final String group;
|
||||
private final PropType type;
|
||||
private final Function<Node, Object> getter;
|
||||
private final BiConsumer<Node, Object> setter;
|
||||
private final boolean readOnly;
|
||||
private final boolean nullable;
|
||||
private final boolean persistent;
|
||||
private final NodeKind refKind;
|
||||
private final List<String> choices;
|
||||
private final String description;
|
||||
|
||||
private Prop(Builder b) {
|
||||
name = b.name;
|
||||
label = b.label;
|
||||
group = b.group;
|
||||
type = b.type;
|
||||
getter = b.getter;
|
||||
setter = b.setter;
|
||||
readOnly = b.readOnly;
|
||||
nullable = b.nullable;
|
||||
persistent = b.persistent;
|
||||
refKind = b.refKind;
|
||||
choices = List.copyOf(b.choices);
|
||||
description = b.description;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** Inspector section, e.g. "Transform", "Type", "Fog". */
|
||||
public String group() {
|
||||
return group;
|
||||
}
|
||||
|
||||
public PropType type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/** Not editable in the UI (still set by import and project loading). */
|
||||
public boolean readOnly() {
|
||||
return readOnly;
|
||||
}
|
||||
|
||||
public boolean nullable() {
|
||||
return nullable;
|
||||
}
|
||||
|
||||
/** Stored in projects (false for derived values such as a strat's type name). */
|
||||
public boolean persistent() {
|
||||
return persistent;
|
||||
}
|
||||
|
||||
public NodeKind refKind() {
|
||||
return refKind;
|
||||
}
|
||||
|
||||
public List<String> choices() {
|
||||
return choices;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public Object get(Node n) {
|
||||
return getter.apply(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value without undo or events (import/loading). Edits go through {@link
|
||||
* Scene#set(Node, Prop, Object)} or a command.
|
||||
*/
|
||||
public void setRaw(Node n, Object v) {
|
||||
if (setter == null) {
|
||||
throw new UnsupportedOperationException("property " + name + " cannot be set");
|
||||
}
|
||||
if (v == null && !nullable) {
|
||||
throw new IllegalArgumentException("property " + name + " must not be null");
|
||||
}
|
||||
if (v != null && !type.valueClass().isInstance(v)) {
|
||||
throw new IllegalArgumentException(
|
||||
"property " + name + " needs " + type.valueClass().getSimpleName() + ", not "
|
||||
+ v.getClass().getSimpleName());
|
||||
}
|
||||
if (v != null && type == PropType.REF && refKind != null && ((Node) v).kind() != refKind) {
|
||||
throw new IllegalArgumentException("property " + name + " needs a " + refKind + " node");
|
||||
}
|
||||
if (v != null && type == PropType.CHOICE && !choices.contains(v)) {
|
||||
throw new IllegalArgumentException("property " + name + ": '" + v + "' is not one of " + choices);
|
||||
}
|
||||
setter.accept(n, v);
|
||||
}
|
||||
|
||||
public boolean settable() {
|
||||
return setter != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <N extends Node> Builder of(
|
||||
String name, String label, PropType type, Function<N, ?> get, BiConsumer<N, ?> set) {
|
||||
Builder b = new Builder();
|
||||
b.name = name;
|
||||
b.label = label;
|
||||
b.type = type;
|
||||
b.getter = n -> ((Function<N, Object>) get).apply((N) n);
|
||||
b.setter = set == null ? null : (n, v) -> ((BiConsumer<N, Object>) set).accept((N) n, v);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Fluent construction; finish with {@link #build()}. */
|
||||
public static final class Builder {
|
||||
private String name;
|
||||
private String label;
|
||||
private String group = "";
|
||||
private PropType type;
|
||||
private Function<Node, Object> getter;
|
||||
private BiConsumer<Node, Object> setter;
|
||||
private boolean readOnly;
|
||||
private boolean nullable;
|
||||
private boolean persistent = true;
|
||||
private NodeKind refKind;
|
||||
private final List<String> choices = new ArrayList<>();
|
||||
private String description = "";
|
||||
|
||||
public Builder group(String g) {
|
||||
group = g;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder readOnly() {
|
||||
readOnly = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder nullable() {
|
||||
nullable = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder derived() {
|
||||
persistent = false;
|
||||
readOnly = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder ref(NodeKind k) {
|
||||
refKind = k;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder choices(List<String> c) {
|
||||
choices.addAll(c);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder desc(String d) {
|
||||
description = d;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Prop build() {
|
||||
return new Prop(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
/**
|
||||
* Semantics of a {@link Prop} value. The Java value class is fixed per type (see {@link
|
||||
* #valueClass()}); the type tells the inspector how to show and edit it.
|
||||
*/
|
||||
public enum PropType {
|
||||
/** Integer, shown decimal. */
|
||||
INT(Integer.class),
|
||||
/** 20.12 fixed point integer (4096 = 1 cell); show as value / 4096. */
|
||||
FIXED(Integer.class),
|
||||
/** Angle in 1/4096 turns (4096 = 360°); show in degrees. */
|
||||
ANGLE(Integer.class),
|
||||
/** Bit flags, show hex. */
|
||||
FLAGS(Integer.class),
|
||||
/** Colour 0x00BBGGRR as stored by the engine (byte order R, G, B, X). */
|
||||
COLOR(Integer.class),
|
||||
DOUBLE(Double.class),
|
||||
BOOL(Boolean.class),
|
||||
STRING(String.class),
|
||||
/** One of {@link Prop#choices()}. */
|
||||
CHOICE(String.class),
|
||||
/** Reference to another node ({@link Prop#refKind()}); {@code null} if allowed. */
|
||||
REF(Node.class),
|
||||
INT_ARRAY(int[].class),
|
||||
DOUBLE_ARRAY(double[].class),
|
||||
BYTES(byte[].class);
|
||||
|
||||
private final Class<?> valueClass;
|
||||
|
||||
PropType(Class<?> c) {
|
||||
valueClass = c;
|
||||
}
|
||||
|
||||
public Class<?> valueClass() {
|
||||
return valueClass;
|
||||
}
|
||||
|
||||
public boolean isArray() {
|
||||
return this == INT_ARRAY || this == DOUBLE_ARRAY || this == BYTES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/** Small helper to declare the prop lists of node classes; props get the current group. */
|
||||
final class Props<N extends Node> {
|
||||
private final List<Prop> list = new ArrayList<>();
|
||||
private String group = "";
|
||||
|
||||
Props<N> group(String g) {
|
||||
group = g;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** A prop with extra settings (read-only, nullable, ref kind, choices, description, …). */
|
||||
<T> Props<N> p(
|
||||
String name,
|
||||
String label,
|
||||
PropType t,
|
||||
Function<N, T> get,
|
||||
BiConsumer<N, T> set,
|
||||
UnaryOperator<Prop.Builder> mod) {
|
||||
list.add(mod.apply(Prop.of(name, label, t, get, set).group(group)).build());
|
||||
return this;
|
||||
}
|
||||
|
||||
<T> Props<N> p(String name, String label, PropType t, Function<N, T> get, BiConsumer<N, T> set) {
|
||||
return p(name, label, t, get, set, b -> b);
|
||||
}
|
||||
|
||||
Props<N> integer(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
|
||||
return p(name, label, PropType.INT, get, set);
|
||||
}
|
||||
|
||||
Props<N> fixed(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
|
||||
return p(name, label, PropType.FIXED, get, set);
|
||||
}
|
||||
|
||||
Props<N> angle(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
|
||||
return p(name, label, PropType.ANGLE, get, set);
|
||||
}
|
||||
|
||||
Props<N> flags(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
|
||||
return p(name, label, PropType.FLAGS, get, set);
|
||||
}
|
||||
|
||||
Props<N> dbl(String name, String label, Function<N, Double> get, BiConsumer<N, Double> set) {
|
||||
return p(name, label, PropType.DOUBLE, get, set);
|
||||
}
|
||||
|
||||
Props<N> str(String name, String label, Function<N, String> get, BiConsumer<N, String> set) {
|
||||
return p(name, label, PropType.STRING, get, set);
|
||||
}
|
||||
|
||||
Props<N> ints(String name, String label, Function<N, int[]> get, BiConsumer<N, int[]> set) {
|
||||
return p(name, label, PropType.INT_ARRAY, get, set);
|
||||
}
|
||||
|
||||
Props<N> doubles(String name, String label, Function<N, double[]> get, BiConsumer<N, double[]> set) {
|
||||
return p(name, label, PropType.DOUBLE_ARRAY, get, set);
|
||||
}
|
||||
|
||||
Props<N> bytes(String name, String label, Function<N, byte[]> get, BiConsumer<N, byte[]> set) {
|
||||
return p(name, label, PropType.BYTES, get, set);
|
||||
}
|
||||
|
||||
List<Prop> build() {
|
||||
return List.copyOf(list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A WAD chunk the editor does not interpret (STPC strat code and object models, sound banks, LNFO,
|
||||
* …), kept verbatim in its original position. Managed chunks (TRAK, MAP, TEXT, LGHT, NAME) appear
|
||||
* in the list as placeholders with {@code data == null} so the chunk order is preserved.
|
||||
*/
|
||||
public final class RawChunk extends Node {
|
||||
String tag = "";
|
||||
byte[] data;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<RawChunk>()
|
||||
.group("Chunk")
|
||||
.p("tag", "Tag", PropType.STRING, n -> n.tag, (n, v) -> n.tag = v, Prop.Builder::readOnly)
|
||||
.p("data", "Data", PropType.BYTES, n -> n.data, (n, v) -> n.data = v, b -> b.nullable()
|
||||
.readOnly())
|
||||
.build();
|
||||
|
||||
public RawChunk(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.RAW_CHUNK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return tag.trim() + (data == null ? " (edited by the editor)" : " (" + data.length + " bytes)");
|
||||
}
|
||||
|
||||
/** Four-character tag as in {@link com.eliasmueller.llamamod.format.Wad} ("MAP ", "STPC"). */
|
||||
public String tag() {
|
||||
return tag;
|
||||
}
|
||||
|
||||
public byte[] data() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public boolean managed() {
|
||||
return data == null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Visibility regions (singleton node "regions:0"): every grid cell has a region 0..31; the camera's
|
||||
* region selects a 32-bit mask of regions whose cells are drawn (MAP blocks 6/7, groove.exe
|
||||
* FUN_0042bf40). Tiles in a cell whose region bit is not in the mask are invisible. The second
|
||||
* value per region is read into DAT_00586424 (meaning unknown).
|
||||
*/
|
||||
public final class Regions extends Node {
|
||||
public static final int COUNT = 32;
|
||||
|
||||
int[] table = new int[2 * COUNT];
|
||||
int[] grid = new int[0];
|
||||
|
||||
private static final List<Prop> PROPS = new Props<Regions>()
|
||||
.group("Regions")
|
||||
.ints("table", "Region table (mask, value) × 32", n -> n.table, (n, v) -> n.table = v)
|
||||
.ints("grid", "Region per cell (row = z)", n -> n.grid, (n, v) -> n.grid = v)
|
||||
.build();
|
||||
|
||||
public Regions(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.REGIONS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "Visibility regions";
|
||||
}
|
||||
|
||||
/** 64 ints: mask0, value0, mask1, value1, … */
|
||||
public int[] table() {
|
||||
return table;
|
||||
}
|
||||
|
||||
public int[] grid() {
|
||||
return grid;
|
||||
}
|
||||
|
||||
public int mask(int region) {
|
||||
return table[2 * region];
|
||||
}
|
||||
|
||||
public int value(int region) {
|
||||
return table[2 * region + 1];
|
||||
}
|
||||
|
||||
/** Region of cell (cx, cz) with the grid width w, or -1 outside. */
|
||||
public int regionAt(int cx, int cz, int w) {
|
||||
int i = cx + cz * w;
|
||||
return cx < 0 || cx >= w || i < 0 || i >= grid.length ? -1 : grid[i];
|
||||
}
|
||||
|
||||
/** Whether cell (cx, cz) is drawn while the camera is in cell (camX, camZ). */
|
||||
public boolean visibleFrom(int camX, int camZ, int cx, int cz, int w) {
|
||||
int rc = regionAt(camX, camZ, w);
|
||||
int r = regionAt(cx, cz, w);
|
||||
return rc >= 0 && r >= 0 && (mask(rc) & (1 << r)) != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* A level as the editor sees it: every part of a level WAD the editor can touch, as nodes with
|
||||
* stable ids, plus brushes and imported meshes that become tiles when the level is built ({@code
|
||||
* editor.io.WadBuilder}). Pure data, not thread-safe; change it on one thread (the EDT) through
|
||||
* {@link com.eliasmueller.llamamod.editor.model.cmd.History} commands to get undo and events.
|
||||
*
|
||||
* <p>Coordinates: tiles and tile models use export space (cells, z ≤ 0); strats, waypoints and
|
||||
* zone targets use game space in 20.12 fixed point (z = −export z); brushes use game space in
|
||||
* cells.
|
||||
*/
|
||||
public final class Scene {
|
||||
private final LevelSettings settings = new LevelSettings("settings:0");
|
||||
private final Regions regions = new Regions("regions:0");
|
||||
private final Map<NodeKind, NodeList<?>> lists = new EnumMap<>(NodeKind.class);
|
||||
private final NodeList<TileModel> tileModels = list(NodeKind.TILE_MODEL);
|
||||
private final NodeList<Tile> tiles = list(NodeKind.TILE);
|
||||
private final NodeList<Strat> strats = list(NodeKind.STRAT);
|
||||
private final NodeList<PathNode> pathNodes = list(NodeKind.PATH_NODE);
|
||||
private final NodeList<EnvZone> zones = list(NodeKind.ENV_ZONE);
|
||||
private final NodeList<Light> lights = list(NodeKind.LIGHT);
|
||||
private final NodeList<TexturePage> pages = list(NodeKind.TEXTURE_PAGE);
|
||||
private final NodeList<TextureEntry> textures = list(NodeKind.TEXTURE);
|
||||
private final NodeList<TextureAnim> textureAnims = list(NodeKind.TEXTURE_ANIM);
|
||||
private final NodeList<Brush> brushes = list(NodeKind.BRUSH);
|
||||
private final NodeList<MeshAsset> meshAssets = list(NodeKind.MESH_ASSET);
|
||||
private final NodeList<RawChunk> chunks = list(NodeKind.RAW_CHUNK);
|
||||
|
||||
private final Map<String, Node> byId = new HashMap<>();
|
||||
private final Map<String, byte[]> assets = new LinkedHashMap<>();
|
||||
private final List<SceneListener> listeners = new CopyOnWriteArrayList<>();
|
||||
private StratCatalog catalog = new StratCatalog();
|
||||
private int nextId = 1;
|
||||
private int mute;
|
||||
|
||||
public Scene() {
|
||||
settings.scene = this;
|
||||
regions.scene = this;
|
||||
byId.put(settings.id(), settings);
|
||||
byId.put(regions.id(), regions);
|
||||
}
|
||||
|
||||
private <T extends Node> NodeList<T> list(NodeKind k) {
|
||||
NodeList<T> l = new NodeList<>(k);
|
||||
lists.put(k, l);
|
||||
return l;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ collections
|
||||
|
||||
public LevelSettings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
public Regions regions() {
|
||||
return regions;
|
||||
}
|
||||
|
||||
public NodeList<TileModel> tileModels() {
|
||||
return tileModels;
|
||||
}
|
||||
|
||||
public NodeList<Tile> tiles() {
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player start: the Llama, else the first object of category "player" (levels where the
|
||||
* player rides or is carried: RollerCoaster, LlamaChase, KuscoAndPachaTogetherOnATree); null if none.
|
||||
*/
|
||||
public Strat playerStart() {
|
||||
Strat other = null;
|
||||
for (Strat st : strats) {
|
||||
if ("Llama".equals(st.type())) {
|
||||
return st;
|
||||
}
|
||||
if (other == null && "player".equals(StratCatalog.category(st.type()))) {
|
||||
other = st;
|
||||
}
|
||||
}
|
||||
return other;
|
||||
}
|
||||
|
||||
public NodeList<Strat> strats() {
|
||||
return strats;
|
||||
}
|
||||
|
||||
public NodeList<PathNode> pathNodes() {
|
||||
return pathNodes;
|
||||
}
|
||||
|
||||
public NodeList<EnvZone> zones() {
|
||||
return zones;
|
||||
}
|
||||
|
||||
public NodeList<Light> lights() {
|
||||
return lights;
|
||||
}
|
||||
|
||||
public NodeList<TexturePage> pages() {
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Texture table; faces use the index in this list. */
|
||||
public NodeList<TextureEntry> textures() {
|
||||
return textures;
|
||||
}
|
||||
|
||||
public NodeList<TextureAnim> textureAnims() {
|
||||
return textureAnims;
|
||||
}
|
||||
|
||||
public NodeList<Brush> brushes() {
|
||||
return brushes;
|
||||
}
|
||||
|
||||
public NodeList<MeshAsset> meshAssets() {
|
||||
return meshAssets;
|
||||
}
|
||||
|
||||
/** WAD chunks in file order; managed chunks are placeholders ({@link RawChunk#managed()}). */
|
||||
public NodeList<RawChunk> chunks() {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** List holding nodes of a kind ({@code null} for the singletons SETTINGS and REGIONS). */
|
||||
@SuppressWarnings("unchecked")
|
||||
public NodeList<Node> listOf(NodeKind k) {
|
||||
return (NodeList<Node>) lists.get(k);
|
||||
}
|
||||
|
||||
/** Collections in a fixed order (project files and outliners). */
|
||||
public static final List<NodeKind> LIST_KINDS = List.of(
|
||||
NodeKind.RAW_CHUNK,
|
||||
NodeKind.TEXTURE_PAGE,
|
||||
NodeKind.TEXTURE,
|
||||
NodeKind.TEXTURE_ANIM,
|
||||
NodeKind.TILE_MODEL,
|
||||
NodeKind.TILE,
|
||||
NodeKind.PATH_NODE,
|
||||
NodeKind.STRAT,
|
||||
NodeKind.ENV_ZONE,
|
||||
NodeKind.LIGHT,
|
||||
NodeKind.MESH_ASSET,
|
||||
NodeKind.BRUSH);
|
||||
|
||||
public Node node(String id) {
|
||||
return byId.get(id);
|
||||
}
|
||||
|
||||
public boolean contains(Node n) {
|
||||
return n != null && byId.get(n.id()) == n;
|
||||
}
|
||||
|
||||
/** Project assets (imported meshes, images): path inside the project zip → bytes. */
|
||||
public Map<String, byte[]> assets() {
|
||||
return assets;
|
||||
}
|
||||
|
||||
public StratCatalog catalog() {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
public void setCatalog(StratCatalog c) {
|
||||
catalog = Objects.requireNonNull(c);
|
||||
for (Strat s : strats) {
|
||||
s.setTypeName(c.nameOf(s.code()));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ids and creation
|
||||
|
||||
/** Next id for a node created in the editor ("kind:nN"). */
|
||||
public String newId(NodeKind k) {
|
||||
String id;
|
||||
do {
|
||||
id = k.prefix() + ":n" + nextId++;
|
||||
} while (byId.containsKey(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
public int nextIdCounter() {
|
||||
return nextId;
|
||||
}
|
||||
|
||||
public void setNextIdCounter(int n) {
|
||||
nextId = Math.max(1, n);
|
||||
}
|
||||
|
||||
/** New detached node of a list kind with a fresh id (add it with a command). */
|
||||
public Node create(NodeKind k) {
|
||||
if (k == NodeKind.BRUSH) {
|
||||
throw new IllegalArgumentException("use createBrush");
|
||||
}
|
||||
return createWithId(k, newId(k), null);
|
||||
}
|
||||
|
||||
public Brush createBrush(BrushKind kind) {
|
||||
return new Brush(newId(NodeKind.BRUSH), kind);
|
||||
}
|
||||
|
||||
/** Node with a given id (import and project loading). */
|
||||
public static Node createWithId(NodeKind k, String id, BrushKind brushKind) {
|
||||
return switch (k) {
|
||||
case TILE_MODEL -> new TileModel(id);
|
||||
case TILE -> new Tile(id);
|
||||
case STRAT -> new Strat(id);
|
||||
case PATH_NODE -> new PathNode(id);
|
||||
case ENV_ZONE -> new EnvZone(id);
|
||||
case LIGHT -> new Light(id);
|
||||
case TEXTURE_PAGE -> new TexturePage(id);
|
||||
case TEXTURE -> new TextureEntry(id);
|
||||
case TEXTURE_ANIM -> new TextureAnim(id);
|
||||
case MESH_ASSET -> new MeshAsset(id);
|
||||
case RAW_CHUNK -> new RawChunk(id);
|
||||
case BRUSH -> new Brush(id, brushKind);
|
||||
case SETTINGS, REGIONS -> throw new IllegalArgumentException(k + " is a singleton");
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ mutation (with events)
|
||||
|
||||
/** Inserts a detached node at index (-1 = end) and fires ADDED. */
|
||||
public void add(Node n, int index) {
|
||||
if (n.scene != null) {
|
||||
throw new IllegalStateException(n.id() + " is already part of a scene");
|
||||
}
|
||||
if (byId.containsKey(n.id())) {
|
||||
throw new IllegalStateException("duplicate node id " + n.id());
|
||||
}
|
||||
NodeList<Node> l = listOf(n.kind());
|
||||
int i = index < 0 || index > l.size() ? l.size() : index;
|
||||
if (i == l.size()) {
|
||||
l.append(n);
|
||||
} else {
|
||||
l.insert(i, n);
|
||||
}
|
||||
n.scene = this;
|
||||
byId.put(n.id(), n);
|
||||
if (n instanceof Strat s) {
|
||||
s.setTypeName(catalog.nameOf(s.code()));
|
||||
}
|
||||
fire(SceneEvent.added(n, i));
|
||||
}
|
||||
|
||||
/** Appends without an event (bulk import). */
|
||||
public void addQuiet(Node n) {
|
||||
mute++;
|
||||
try {
|
||||
add(n, -1);
|
||||
} finally {
|
||||
mute--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a node and fires REMOVED; returns its former index. References to it are not touched. */
|
||||
public int remove(Node n) {
|
||||
NodeList<Node> l = listOf(n.kind());
|
||||
int i = l == null ? -1 : l.indexOf(n);
|
||||
if (i < 0) {
|
||||
throw new IllegalStateException(n.id() + " is not part of this scene");
|
||||
}
|
||||
l.delete(i);
|
||||
byId.remove(n.id());
|
||||
n.scene = null;
|
||||
fire(SceneEvent.removed(n, i));
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Sets a property and fires CHANGED (no undo; use {@code SetProp} for that). */
|
||||
public void set(Node n, Prop p, Object value) {
|
||||
Object old = p.get(n);
|
||||
p.setRaw(n, value);
|
||||
if (n instanceof Strat s && p.name().equals("code")) {
|
||||
s.setTypeName(catalog.nameOf(s.code()));
|
||||
}
|
||||
fire(SceneEvent.changed(n, p, old));
|
||||
}
|
||||
|
||||
public void set(Node n, String prop, Object value) {
|
||||
set(n, n.prop(prop), value);
|
||||
}
|
||||
|
||||
/** Removes all nodes of a list kind without events (templates, loading). */
|
||||
public void clearQuiet(NodeKind k) {
|
||||
NodeList<Node> l = listOf(k);
|
||||
for (Node n : l) {
|
||||
byId.remove(n.id());
|
||||
n.scene = null;
|
||||
}
|
||||
l.clearAll();
|
||||
}
|
||||
|
||||
/** Puts the nodes of a list kind into the given order (same set of nodes) and fires RELOADED. */
|
||||
public void reorder(NodeKind k, List<? extends Node> order) {
|
||||
NodeList<Node> l = listOf(k);
|
||||
if (order.size() != l.size() || !order.stream().allMatch(l::contains)) {
|
||||
throw new IllegalArgumentException("reorder needs exactly the nodes of the list");
|
||||
}
|
||||
List<Node> copy = new ArrayList<>(order);
|
||||
l.clearAll();
|
||||
for (Node n : copy) {
|
||||
l.append(n);
|
||||
}
|
||||
fire(SceneEvent.reloaded());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ queries
|
||||
|
||||
/** Properties (of nodes in this scene) that reference {@code target}. */
|
||||
public List<Map.Entry<Node, Prop>> referencesTo(Node target) {
|
||||
List<Map.Entry<Node, Prop>> out = new ArrayList<>();
|
||||
for (NodeKind k : LIST_KINDS) {
|
||||
for (Node n : listOf(k)) {
|
||||
for (Prop p : n.props()) {
|
||||
if (p.type() == PropType.REF && p.get(n) == target) {
|
||||
out.add(Map.entry(n, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Nodes of a chain from start, following {@code next} (stops at loops). */
|
||||
public List<PathNode> chain(PathNode start) {
|
||||
List<PathNode> out = new ArrayList<>();
|
||||
Map<PathNode, Boolean> seen = new IdentityHashMap<>();
|
||||
for (PathNode p = start; p != null && seen.put(p, true) == null; p = p.next()) {
|
||||
out.add(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ events
|
||||
|
||||
public void addListener(SceneListener l) {
|
||||
listeners.add(l);
|
||||
}
|
||||
|
||||
public void removeListener(SceneListener l) {
|
||||
listeners.remove(l);
|
||||
}
|
||||
|
||||
public void fire(SceneEvent e) {
|
||||
if (mute > 0) {
|
||||
return;
|
||||
}
|
||||
for (SceneListener l : listeners) {
|
||||
l.sceneChanged(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs r without events, then fires RELOADED (bulk changes). */
|
||||
public void bulk(Runnable r) {
|
||||
mute++;
|
||||
try {
|
||||
r.run();
|
||||
} finally {
|
||||
mute--;
|
||||
}
|
||||
fire(SceneEvent.reloaded());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
/**
|
||||
* Change notification. ADDED/REMOVED carry the list index; CHANGED the property and its previous
|
||||
* value; RELOADED means "everything may have changed" (project loaded, bulk operation).
|
||||
*/
|
||||
public record SceneEvent(Type type, Node node, Prop prop, int index, Object oldValue) {
|
||||
public enum Type {
|
||||
ADDED,
|
||||
REMOVED,
|
||||
CHANGED,
|
||||
RELOADED
|
||||
}
|
||||
|
||||
public static SceneEvent added(Node n, int i) {
|
||||
return new SceneEvent(Type.ADDED, n, null, i, null);
|
||||
}
|
||||
|
||||
public static SceneEvent removed(Node n, int i) {
|
||||
return new SceneEvent(Type.REMOVED, n, null, i, null);
|
||||
}
|
||||
|
||||
public static SceneEvent changed(Node n, Prop p, Object old) {
|
||||
return new SceneEvent(Type.CHANGED, n, p, -1, old);
|
||||
}
|
||||
|
||||
public static SceneEvent reloaded() {
|
||||
return new SceneEvent(Type.RELOADED, null, null, -1, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
/** Receives {@link SceneEvent}s on the thread that changed the scene (normally the Swing EDT). */
|
||||
@FunctionalInterface
|
||||
public interface SceneListener {
|
||||
void sceneChanged(SceneEvent e);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Object instance ("strat", MAP 58-byte record). The type is the strat code offset in STPC
|
||||
* ({@code code}); {@code type} is its name from the code. Position is 20.12 fixed point in game
|
||||
* space (x east, y up, z = grid row, positive), angles 4096 = 360° (ry: 0 = facing +z, 1024 = +x).
|
||||
* {@code nVars}, {@code slots}, {@code typeFlags}, {@code extra} are constant per type (copy them
|
||||
* from {@link StratCatalog}). {@code params} are pushed on the strat's stack (MAP parameter pool).
|
||||
*/
|
||||
public final class Strat extends Node {
|
||||
/** Flag bit: not spawned at level start (spawned by other strats). */
|
||||
public static final int FLAG_HIDDEN = 2;
|
||||
|
||||
String type = "";
|
||||
int code;
|
||||
int rx;
|
||||
int ry;
|
||||
int rz;
|
||||
int x;
|
||||
int y;
|
||||
int z;
|
||||
int[] params = new int[0];
|
||||
Integer paramIndex;
|
||||
int nVars;
|
||||
int slots;
|
||||
int typeFlags;
|
||||
int extra;
|
||||
PathNode waypoint;
|
||||
int waypointRaw = -1;
|
||||
int zero;
|
||||
int flags;
|
||||
int last;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<Strat>()
|
||||
.group("Type")
|
||||
.p("type", "Type", PropType.STRING, n -> n.type, (n, v) -> n.type = v, Prop.Builder::derived)
|
||||
.p(
|
||||
"code",
|
||||
"Code offset (STPC)",
|
||||
PropType.FLAGS,
|
||||
n -> n.code,
|
||||
(n, v) -> n.code = v,
|
||||
b -> b.desc("selects the type; set together with the type fields (StratCatalog)"))
|
||||
.integer("nVars", "Variables", n -> n.nVars, (n, v) -> n.nVars = v)
|
||||
.integer("slots", "Slots", n -> n.slots, (n, v) -> n.slots = v)
|
||||
.flags("typeFlags", "Type flags", n -> n.typeFlags, (n, v) -> n.typeFlags = v)
|
||||
.integer("extra", "Extra memory", n -> n.extra, (n, v) -> n.extra = v)
|
||||
.group("Transform")
|
||||
.fixed("x", "X", n -> n.x, (n, v) -> n.x = v)
|
||||
.fixed("y", "Y", n -> n.y, (n, v) -> n.y = v)
|
||||
.fixed("z", "Z", n -> n.z, (n, v) -> n.z = v)
|
||||
.angle("rx", "Rotation X", n -> n.rx, (n, v) -> n.rx = v)
|
||||
.angle("ry", "Rotation Y (yaw)", n -> n.ry, (n, v) -> n.ry = v)
|
||||
.angle("rz", "Rotation Z", n -> n.rz, (n, v) -> n.rz = v)
|
||||
.group("Behaviour")
|
||||
.ints("params", "Parameters", n -> n.params, (n, v) -> n.params = v)
|
||||
.p("waypoint", "Waypoint chain", PropType.REF, n -> n.waypoint, (n, v) -> n.waypoint = v, b -> b.ref(
|
||||
NodeKind.PATH_NODE)
|
||||
.nullable())
|
||||
.flags("flags", "Flags (2 = not spawned at start)", n -> n.flags, (n, v) -> n.flags = v)
|
||||
.group("Raw")
|
||||
.p(
|
||||
"paramIndex",
|
||||
"Original pool index",
|
||||
PropType.INT,
|
||||
n -> n.paramIndex,
|
||||
(n, v) -> n.paramIndex = v,
|
||||
b -> b.nullable().readOnly())
|
||||
.integer("waypointRaw", "Waypoint index without chain", n -> n.waypointRaw, (n, v) -> n.waypointRaw = v)
|
||||
.integer("zero", "Field +50", n -> n.zero, (n, v) -> n.zero = v)
|
||||
.integer("last", "Field +56", n -> n.last, (n, v) -> n.last = v)
|
||||
.build();
|
||||
|
||||
public Strat(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.STRAT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return (type.isEmpty() ? "?" : type) + " (" + id() + ")";
|
||||
}
|
||||
|
||||
public String type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/** Called when the code offset is resolved against a catalog (not undoable, derived). */
|
||||
public void setTypeName(String t) {
|
||||
type = t == null ? "" : t;
|
||||
}
|
||||
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public int rx() {
|
||||
return rx;
|
||||
}
|
||||
|
||||
public int ry() {
|
||||
return ry;
|
||||
}
|
||||
|
||||
public int rz() {
|
||||
return rz;
|
||||
}
|
||||
|
||||
public int x() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public int y() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public int z() {
|
||||
return z;
|
||||
}
|
||||
|
||||
public double gx() {
|
||||
return x / 4096.0;
|
||||
}
|
||||
|
||||
public double gy() {
|
||||
return y / 4096.0;
|
||||
}
|
||||
|
||||
public double gz() {
|
||||
return z / 4096.0;
|
||||
}
|
||||
|
||||
/** Yaw in degrees (0 = facing +z, 90 = facing +x). */
|
||||
public double yawDegrees() {
|
||||
return (ry & 0xfff) * 360.0 / 4096;
|
||||
}
|
||||
|
||||
public int[] params() {
|
||||
return params;
|
||||
}
|
||||
|
||||
public Integer paramIndex() {
|
||||
return paramIndex;
|
||||
}
|
||||
|
||||
public int nVars() {
|
||||
return nVars;
|
||||
}
|
||||
|
||||
public int slots() {
|
||||
return slots;
|
||||
}
|
||||
|
||||
public int typeFlags() {
|
||||
return typeFlags;
|
||||
}
|
||||
|
||||
public int extra() {
|
||||
return extra;
|
||||
}
|
||||
|
||||
public PathNode waypoint() {
|
||||
return waypoint;
|
||||
}
|
||||
|
||||
public int waypointRaw() {
|
||||
return waypointRaw;
|
||||
}
|
||||
|
||||
public int zero() {
|
||||
return zero;
|
||||
}
|
||||
|
||||
public int flags() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
public int last() {
|
||||
return last;
|
||||
}
|
||||
|
||||
public boolean hidden() {
|
||||
return (flags & FLAG_HIDDEN) != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* Object types of the base level (derived from its STPC code and MAP instances, not stored in
|
||||
* projects). Only types with an original instance are placeable, because the per-type fields
|
||||
* (variables, slots, flags, extra memory) come from the compiler and are copied from it. Each code
|
||||
* entry of a type is a {@link Variant} (e.g. SmashPot has two spawn stubs).
|
||||
*/
|
||||
public final class StratCatalog {
|
||||
/**
|
||||
* Prototype of one code entry: type fields and default parameters of the first original
|
||||
* instance, and its waypoint chain relative to the instance position.
|
||||
*
|
||||
* @param chain per node {dx, dy, dz, rx, ry, rz, unk1, unk2, unk3} (fixed point relative to the
|
||||
* prototype position)
|
||||
*/
|
||||
public record Variant(
|
||||
int code,
|
||||
int nVars,
|
||||
int slots,
|
||||
int typeFlags,
|
||||
int extra,
|
||||
int flags,
|
||||
int rx,
|
||||
int ry,
|
||||
int rz,
|
||||
int[] params,
|
||||
List<int[]> chain,
|
||||
String sourceLevel) {
|
||||
public int nParams() {
|
||||
return params.length;
|
||||
}
|
||||
|
||||
public boolean hasWaypoints() {
|
||||
return !chain.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public record Type(String name, List<Variant> variants) {
|
||||
public Variant variant(int i) {
|
||||
return variants.get(i);
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, Type> types = new TreeMap<>();
|
||||
private final Map<Integer, String> names = new LinkedHashMap<>();
|
||||
private final List<String> codeOnly = new ArrayList<>();
|
||||
|
||||
/** Adds a variant (types are created on first use; variants with a known code are ignored). */
|
||||
public void add(String name, Variant v) {
|
||||
Type t = types.computeIfAbsent(name, n -> new Type(n, new ArrayList<>()));
|
||||
if (t.variants.stream().noneMatch(x -> x.code == v.code)) {
|
||||
t.variants.add(v);
|
||||
}
|
||||
names.putIfAbsent(v.code, name);
|
||||
}
|
||||
|
||||
/** A named strat entry of the code that has no instance (not placeable). */
|
||||
public void addCodeOnly(String name, int code) {
|
||||
names.putIfAbsent(code, name);
|
||||
if (!types.containsKey(name) && !codeOnly.contains(name)) {
|
||||
codeOnly.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
public void addName(int code, String name) {
|
||||
names.putIfAbsent(code, name);
|
||||
}
|
||||
|
||||
/** Placeable types, sorted by name. */
|
||||
public List<Type> types() {
|
||||
return List.copyOf(types.values());
|
||||
}
|
||||
|
||||
public Type type(String name) {
|
||||
return types.get(name);
|
||||
}
|
||||
|
||||
public boolean has(String name) {
|
||||
return types.containsKey(name);
|
||||
}
|
||||
|
||||
/** Type name for a code offset, or {@code null}. */
|
||||
public String nameOf(int code) {
|
||||
return names.get(code);
|
||||
}
|
||||
|
||||
/** Every known code offset with its type name (read-only). */
|
||||
public Map<Integer, String> names() {
|
||||
return Collections.unmodifiableMap(names);
|
||||
}
|
||||
|
||||
/** Strat names in the code without an instance to copy type fields from. */
|
||||
public List<String> codeOnly() {
|
||||
return Collections.unmodifiableList(codeOnly);
|
||||
}
|
||||
|
||||
/** Debug-view category of a type name (same colours as mods.debug), for icons and filters. */
|
||||
public static String category(String name) {
|
||||
if (name == null) {
|
||||
return "other";
|
||||
}
|
||||
if (name.equals("Llama")
|
||||
|| name.equals("KuscoAndPachaTogetherOnATree")
|
||||
|| name.equals("RollerCoaster")
|
||||
|| name.equals("LlamaChase")) {
|
||||
return "player";
|
||||
}
|
||||
if (name.contains("LevelDoor") || name.equals("VillageEndDoor") || name.equals("EndGameDoor")) {
|
||||
return "exit";
|
||||
}
|
||||
if (name.startsWith("CheckPoint")) {
|
||||
return "checkpoint";
|
||||
}
|
||||
if (name.startsWith("Camera") || name.startsWith("CutSceneCamera") || name.endsWith("Camera")) {
|
||||
return "camera";
|
||||
}
|
||||
if (name.contains("Trigger") || name.startsWith("Say")) {
|
||||
return "trigger";
|
||||
}
|
||||
if (name.startsWith("Coin") || name.equals("Secret") || name.startsWith("Health") || name.equals("RubyIdol")) {
|
||||
return "collectible";
|
||||
}
|
||||
if (name.startsWith("Force") || name.contains("Rebound") || name.contains("Current")) {
|
||||
return "force";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Texture animation (TEXT, FUN_00406e40): animates texture entry {@code texture}. {@code data} are
|
||||
* the payload bytes by type: 1/2 = 3 bytes, 7/9 = u16, u16, u8, u8, 8 = u8, u8 n, u8, n × u16
|
||||
* texture index (flip-book), 10/11 = u8, u8, u8, u8 n, n × u16; other types have no payload.
|
||||
*/
|
||||
public final class TextureAnim extends Node {
|
||||
int texture;
|
||||
int type;
|
||||
byte[] data = new byte[0];
|
||||
|
||||
private static final List<Prop> PROPS = new Props<TextureAnim>()
|
||||
.group("Animation")
|
||||
.integer("texture", "Texture index", n -> n.texture, (n, v) -> n.texture = v)
|
||||
.integer("type", "Type", n -> n.type, (n, v) -> n.type = v)
|
||||
.bytes("data", "Payload", n -> n.data, (n, v) -> n.data = v)
|
||||
.build();
|
||||
|
||||
public TextureAnim(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.TEXTURE_ANIM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "animation type " + type + " of #" + texture;
|
||||
}
|
||||
|
||||
public int texture() {
|
||||
return texture;
|
||||
}
|
||||
|
||||
public int type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public byte[] data() {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Payload length of a type, or -1 if it depends on a count byte. */
|
||||
public static int fixedPayload(int type) {
|
||||
return switch (type) {
|
||||
case 1, 2 -> 3;
|
||||
case 7, 9 -> 6;
|
||||
case 8, 10, 11 -> -1;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
/** Flip-book frames (types 8, 10, 11) as texture indices, else empty. */
|
||||
public int[] frames() {
|
||||
int n;
|
||||
int o;
|
||||
if (type == 8 && data.length >= 3) {
|
||||
n = data[1] & 0xff;
|
||||
o = 3;
|
||||
} else if ((type == 10 || type == 11) && data.length >= 4) {
|
||||
n = data[3] & 0xff;
|
||||
o = 4;
|
||||
} else {
|
||||
return new int[0];
|
||||
}
|
||||
int[] f = new int[n];
|
||||
for (int i = 0; i < n && o + 2 * i + 1 < data.length; i++) {
|
||||
f[i] = (data[o + 2 * i] & 0xff) | (data[o + 2 * i + 1] & 0xff) << 8;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Texture table entry (TEXT, 8 bytes): a rectangle u0..u1 × v0..v1 (inclusive) on page {@code page}
|
||||
* (−1 = flat colour: u0, u1, v0 = r, g, b, v1 = alpha). Faces reference entries by their index in
|
||||
* {@link Scene#textures()}, so entries are only ever appended. Flags: bit 0 flat colour, bit 2
|
||||
* one-pixel border, bits 12-14 blend mode (1 = alpha from v1 for flat colours). The engine has no
|
||||
* free UVs: each triangle shows one half of the rectangle ({@link
|
||||
* com.eliasmueller.llamamod.format.Geometry#faceUvs}).
|
||||
*/
|
||||
public final class TextureEntry extends Node {
|
||||
public static final int FLAT = 0x1;
|
||||
|
||||
int flags;
|
||||
int page;
|
||||
int page2 = -1;
|
||||
int u0;
|
||||
int u1;
|
||||
int v0;
|
||||
int v1;
|
||||
String name = "";
|
||||
|
||||
private static final List<Prop> PROPS = new Props<TextureEntry>()
|
||||
.group("Texture")
|
||||
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
|
||||
.flags("flags", "Flags", n -> n.flags, (n, v) -> n.flags = v)
|
||||
.integer("page", "Page (−1 = flat colour)", n -> n.page, (n, v) -> n.page = v)
|
||||
.integer("page2", "Second page / CLUT", n -> n.page2, (n, v) -> n.page2 = v)
|
||||
.group("Rectangle")
|
||||
.integer("u0", "U0 (red)", n -> n.u0, (n, v) -> n.u0 = v)
|
||||
.integer("u1", "U1 (green, inclusive)", n -> n.u1, (n, v) -> n.u1 = v)
|
||||
.integer("v0", "V0 (blue)", n -> n.v0, (n, v) -> n.v0 = v)
|
||||
.integer("v1", "V1 (alpha, inclusive)", n -> n.v1, (n, v) -> n.v1 = v)
|
||||
.build();
|
||||
|
||||
public TextureEntry(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.TEXTURE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
Scene s = scene();
|
||||
String idx = s == null ? id() : "#" + s.textures().indexOf(this);
|
||||
return (name.isEmpty() ? "texture " : name + " ") + idx;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int flags() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
public int page() {
|
||||
return page;
|
||||
}
|
||||
|
||||
public int page2() {
|
||||
return page2;
|
||||
}
|
||||
|
||||
public int u0() {
|
||||
return u0;
|
||||
}
|
||||
|
||||
public int u1() {
|
||||
return u1;
|
||||
}
|
||||
|
||||
public int v0() {
|
||||
return v0;
|
||||
}
|
||||
|
||||
public int v1() {
|
||||
return v1;
|
||||
}
|
||||
|
||||
/** Width in texels (u0..u1 inclusive). */
|
||||
public int width() {
|
||||
return u1 - u0 + 1;
|
||||
}
|
||||
|
||||
/** Height in texels (v0..v1 inclusive). */
|
||||
public int height() {
|
||||
return v1 - v0 + 1;
|
||||
}
|
||||
|
||||
public boolean isFlat() {
|
||||
return page < 0 || (flags & FLAT) != 0;
|
||||
}
|
||||
|
||||
/** Flat colour as 0xRRGGBB. */
|
||||
public int flatRgb() {
|
||||
return u0 << 16 | u1 << 8 | v0;
|
||||
}
|
||||
|
||||
/** Same as {@link com.eliasmueller.llamamod.format.TextChunk.TextureEntry} for format code. */
|
||||
public com.eliasmueller.llamamod.format.TextChunk.TextureEntry toFormat() {
|
||||
return new com.eliasmueller.llamamod.format.TextChunk.TextureEntry(flags, page, page2, u0, u1, v0, v1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Texture page (TEXT): w × h pixels (all originals 256 × 256), row-major. flags & 0x80 = stored
|
||||
* RLE-compressed; flags & 0x7f = format/blend class (1 opaque, 4/7 alpha, 5 blend 3, …); flags 0 =
|
||||
* 8-bit palettised (pixels are indices). Otherwise pixels are ABGR1555 (bits 0-4 R, 15 = STP;
|
||||
* 0x0000 transparent), see {@link TextChunk#abgr1555ToRgba}.
|
||||
*/
|
||||
public final class TexturePage extends Node {
|
||||
int flags = 0x81;
|
||||
int width = 256;
|
||||
int height = 256;
|
||||
int[] pixels = new int[256 * 256];
|
||||
String name = "";
|
||||
/** Encoded page as read (cache for byte-exact rebuilds), dropped when pixels or flags change. */
|
||||
byte[] encoded;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<TexturePage>()
|
||||
.group("Page")
|
||||
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
|
||||
.flags("flags", "Flags (0x80 RLE, 0x7f format)", n -> n.flags, (n, v) -> {
|
||||
n.flags = v;
|
||||
n.encoded = null;
|
||||
})
|
||||
.p("width", "Width", PropType.INT, n -> n.width, (n, v) -> n.width = v, Prop.Builder::readOnly)
|
||||
.p("height", "Height", PropType.INT, n -> n.height, (n, v) -> n.height = v, Prop.Builder::readOnly)
|
||||
.ints("pixels", "Pixels (ABGR1555)", n -> n.pixels, (n, v) -> {
|
||||
n.pixels = v;
|
||||
n.encoded = null;
|
||||
})
|
||||
.build();
|
||||
|
||||
public TexturePage(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.TEXTURE_PAGE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return (name.isEmpty() ? "page " + id() : name) + " (" + width + "×" + height + ")";
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int flags() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
public int width() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public int[] pixels() {
|
||||
return pixels;
|
||||
}
|
||||
|
||||
public byte[] encoded() {
|
||||
return encoded;
|
||||
}
|
||||
|
||||
public void setEncodedCache(byte[] e) {
|
||||
encoded = e;
|
||||
}
|
||||
|
||||
/** Pixel as 0xRRGGBBAA (palettised pages: grey from the index). */
|
||||
public int rgba(int px, int py) {
|
||||
int v = pixels[py * width + px];
|
||||
if (flags == 0) {
|
||||
return v << 24 | v << 16 | v << 8 | 0xff;
|
||||
}
|
||||
return TextChunk.abgr1555ToRgba(v);
|
||||
}
|
||||
|
||||
/** Whole page as 0xAARRGGBB for {@code BufferedImage.TYPE_INT_ARGB}. */
|
||||
public int[] argb() {
|
||||
int[] out = new int[pixels.length];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int c = rgba(i % width, i / width);
|
||||
out[i] = (c & 0xff) << 24 | c >>> 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Geometry;
|
||||
import com.eliasmueller.llamamod.format.PyMath;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Placed tile (MAP): a {@link TileModel} at (x, y, z) in export space (cells; z ≤ 0, game z = −z)
|
||||
* turned by {@code rot}/4096 turns about y. The tile belongs to grid cell (floor(x), floor(−z)); the
|
||||
* engine tests collision only against the tiles of the player's cell.
|
||||
*
|
||||
* <p>Vertex colours: {@code colors} holds frames × vertexCount(model) RGBA words (0x80 = 1.0, the
|
||||
* engine doubles them); byte 3 of the first word is the frame count (0/1 = one frame). A tile with
|
||||
* a swap (WFPC 0x10000, e.g. doors: stMapreplace) has {@code swapCell} {cellX, y, cellZ}, the swap
|
||||
* models and {@code altColors} for {@code swapAltModel}.
|
||||
*/
|
||||
public final class Tile extends Node {
|
||||
public static final int GREY = 0x00808080;
|
||||
|
||||
double x;
|
||||
double y;
|
||||
double z;
|
||||
int rot;
|
||||
TileModel model;
|
||||
int alt;
|
||||
int[] fixed;
|
||||
int[] colors = new int[0];
|
||||
int[] swapCell;
|
||||
TileModel swapModel;
|
||||
TileModel swapAltModel;
|
||||
int[] altColors;
|
||||
boolean excluded;
|
||||
|
||||
private static final List<Prop> PROPS = new Props<Tile>()
|
||||
.group("Transform")
|
||||
.dbl("x", "X (cells)", n -> n.x, (n, v) -> {
|
||||
n.x = v;
|
||||
n.fixed = null;
|
||||
})
|
||||
.dbl("y", "Y (height)", n -> n.y, (n, v) -> {
|
||||
n.y = v;
|
||||
n.fixed = null;
|
||||
})
|
||||
.dbl("z", "Z (cells, ≤ 0)", n -> n.z, (n, v) -> {
|
||||
n.z = v;
|
||||
n.fixed = null;
|
||||
})
|
||||
.angle("rot", "Rotation", n -> n.rot, (n, v) -> {
|
||||
n.rot = v;
|
||||
n.fixed = null;
|
||||
})
|
||||
.group("Model")
|
||||
.p("model", "Model", PropType.REF, n -> n.model, (n, v) -> n.model = v, b -> b.ref(NodeKind.TILE_MODEL))
|
||||
.group("Swap")
|
||||
.p(
|
||||
"alt",
|
||||
"Swap flag",
|
||||
PropType.INT,
|
||||
n -> n.alt,
|
||||
(n, v) -> n.alt = v,
|
||||
b -> b.desc("≠ 0: the tile has a swap entry (WFPC 0x10000)"))
|
||||
.p(
|
||||
"swapCell",
|
||||
"Swap cell (x, y, z)",
|
||||
PropType.INT_ARRAY,
|
||||
n -> n.swapCell,
|
||||
(n, v) -> n.swapCell = v,
|
||||
b -> b.nullable())
|
||||
.p("swapModel", "Swap model", PropType.REF, n -> n.swapModel, (n, v) -> n.swapModel = v, b -> b.ref(
|
||||
NodeKind.TILE_MODEL)
|
||||
.nullable())
|
||||
.p(
|
||||
"swapAltModel",
|
||||
"Alternative model",
|
||||
PropType.REF,
|
||||
n -> n.swapAltModel,
|
||||
(n, v) -> n.swapAltModel = v,
|
||||
b -> b.ref(NodeKind.TILE_MODEL).nullable())
|
||||
.group("Build")
|
||||
.p(
|
||||
"excluded",
|
||||
"Exclude from build",
|
||||
PropType.BOOL,
|
||||
n -> n.excluded,
|
||||
(n, v) -> n.excluded = v,
|
||||
b -> b.desc(
|
||||
"the tile stays in the project but is left out of the built level (Hide only affects the"
|
||||
+ " editor view)"))
|
||||
.group("Colours")
|
||||
.ints("colors", "Vertex colours (frames × vertices)", n -> n.colors, (n, v) -> n.colors = v)
|
||||
.p(
|
||||
"altColors",
|
||||
"Alternative model colours",
|
||||
PropType.INT_ARRAY,
|
||||
n -> n.altColors,
|
||||
(n, v) -> n.altColors = v,
|
||||
Prop.Builder::nullable)
|
||||
.group("Raw")
|
||||
.p(
|
||||
"fixed",
|
||||
"Fixed-point placement (rx ry rz x y z)",
|
||||
PropType.INT_ARRAY,
|
||||
n -> n.fixed,
|
||||
(n, v) -> n.fixed = v,
|
||||
b -> b.nullable()
|
||||
.readOnly()
|
||||
.desc("only kept when it differs from the derived value; cleared on every move"))
|
||||
.build();
|
||||
|
||||
public Tile(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.TILE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "tile " + cellX() + "," + cellZ() + " " + (model == null ? "?" : model.id());
|
||||
}
|
||||
|
||||
/** True when the tile is kept in the project but not written to the built level. */
|
||||
public boolean excluded() {
|
||||
return excluded;
|
||||
}
|
||||
|
||||
public double x() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public double y() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public double z() {
|
||||
return z;
|
||||
}
|
||||
|
||||
public int rot() {
|
||||
return rot;
|
||||
}
|
||||
|
||||
public TileModel model() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public int alt() {
|
||||
return alt;
|
||||
}
|
||||
|
||||
public int[] fixed() {
|
||||
return fixed;
|
||||
}
|
||||
|
||||
public int[] colors() {
|
||||
return colors;
|
||||
}
|
||||
|
||||
public int[] swapCell() {
|
||||
return swapCell;
|
||||
}
|
||||
|
||||
public TileModel swapModel() {
|
||||
return swapModel;
|
||||
}
|
||||
|
||||
public TileModel swapAltModel() {
|
||||
return swapAltModel;
|
||||
}
|
||||
|
||||
public int[] altColors() {
|
||||
return altColors;
|
||||
}
|
||||
|
||||
public boolean hasSwap() {
|
||||
return swapCell != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a swap model has collision of this material: door objects (RubyIdolDoor & co., ops
|
||||
* 0x11a/0x11b) swap the tile when they open, and in many originals only the open model holds
|
||||
* the exit floor (material 16) of the LevelDoor's cell.
|
||||
*/
|
||||
public boolean swapHasMaterial(int material) {
|
||||
return hasSwap()
|
||||
&& (swapModel != null && swapModel.hasCollisionMaterial(material)
|
||||
|| swapAltModel != null && swapAltModel.hasCollisionMaterial(material));
|
||||
}
|
||||
|
||||
/** Grid column, like MAP's cell lists (floor(x)). */
|
||||
public int cellX() {
|
||||
return (int) Math.floor(x);
|
||||
}
|
||||
|
||||
/** Grid row (floor(−z)). */
|
||||
public int cellZ() {
|
||||
return (int) Math.floor(-z);
|
||||
}
|
||||
|
||||
/** Fixed-point placement the engine uses: stored value or derived from x, y, z, rot. */
|
||||
public int[] effectiveFixed() {
|
||||
return fixed != null ? fixed : deriveFixed(x, y, z, rot);
|
||||
}
|
||||
|
||||
public static int[] deriveFixed(double x, double y, double z, int rot) {
|
||||
return new int[] {
|
||||
0,
|
||||
rot,
|
||||
0,
|
||||
Math.toIntExact(PyMath.round(x * 4096)),
|
||||
Math.toIntExact(PyMath.round(y * 4096)),
|
||||
Math.toIntExact(PyMath.round(-z * 4096))
|
||||
};
|
||||
}
|
||||
|
||||
/** Collision polygons of the model in world export space (none without a model). */
|
||||
public List<TileModel.PlacedPolygon> worldCollisionPolygons() {
|
||||
return model == null ? List.of() : model.collisionPolygons(transform());
|
||||
}
|
||||
|
||||
/** Local (export space) vertex → world (export space). */
|
||||
public Geometry.TileXform transform() {
|
||||
return Geometry.TileXform.of(x, y, z, Integer.toUnsignedLong(rot));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.eliasmueller.llamamod.editor.model;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Collision;
|
||||
import com.eliasmueller.llamamod.format.Geometry;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* TRAK tile model in local export space (y up, z mirrored relative to game space; the tile's
|
||||
* placement adds the cell position). Packed arrays:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code vertices}: 6 doubles per vertex (x, y, z, nx, ny, nz), all float32 values
|
||||
* <li>{@code faces}: 6 ints per triangle (flags, v0, v1, v2, texture index, pad)
|
||||
* <li>{@code planes}: 4 doubles per triangle (nx, ny, nz, d), used by the engine for culling
|
||||
* <li>{@code collision0..2}: 32-byte collision records in game space ({@link Collision}); the
|
||||
* engine always uses all three groups together
|
||||
* </ul>
|
||||
*
|
||||
* {@code header} holds the stored bounding box (0x6c bytes); it is cleared when the vertices change
|
||||
* and then recomputed by the build.
|
||||
*/
|
||||
public final class TileModel extends Node {
|
||||
public static final int VERTEX_STRIDE = 6;
|
||||
public static final int FACE_STRIDE = 6;
|
||||
public static final int PLANE_STRIDE = 4;
|
||||
public static final int COLLISION_SIZE = 32;
|
||||
|
||||
double[] vertices = new double[0];
|
||||
int[] faces = new int[0];
|
||||
double[] planes = new double[0];
|
||||
byte[] collision0 = new byte[0];
|
||||
byte[] collision1 = new byte[0];
|
||||
byte[] collision2 = new byte[0];
|
||||
byte[] header;
|
||||
String name = "";
|
||||
|
||||
private static final List<Prop> PROPS = new Props<TileModel>()
|
||||
.group("Model")
|
||||
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
|
||||
.group("Geometry")
|
||||
.doubles("vertices", "Vertices (x y z nx ny nz)", n -> n.vertices, (n, v) -> {
|
||||
n.vertices = v;
|
||||
n.header = null;
|
||||
})
|
||||
.ints("faces", "Faces (flags v0 v1 v2 tex pad)", n -> n.faces, (n, v) -> n.faces = v)
|
||||
.doubles("planes", "Face planes (nx ny nz d)", n -> n.planes, (n, v) -> n.planes = v)
|
||||
.group("Collision")
|
||||
.bytes("collision0", "Collision group A", n -> n.collision0, (n, v) -> n.collision0 = v)
|
||||
.bytes("collision1", "Collision group B", n -> n.collision1, (n, v) -> n.collision1 = v)
|
||||
.bytes("collision2", "Collision group C", n -> n.collision2, (n, v) -> n.collision2 = v)
|
||||
.group("Raw")
|
||||
.p("header", "Bounding box header", PropType.BYTES, n -> n.header, (n, v) -> n.header = v, b -> b.nullable()
|
||||
.readOnly())
|
||||
.build();
|
||||
|
||||
public TileModel(String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NodeKind kind() {
|
||||
return NodeKind.TILE_MODEL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Prop> props() {
|
||||
return PROPS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
String n = name.isEmpty() ? id() : name;
|
||||
return n + " (" + vertexCount() + " v, " + faceCount() + " f, " + collisionCount() + " c)";
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public double[] vertices() {
|
||||
return vertices;
|
||||
}
|
||||
|
||||
public int[] faces() {
|
||||
return faces;
|
||||
}
|
||||
|
||||
public double[] planes() {
|
||||
return planes;
|
||||
}
|
||||
|
||||
public byte[] collision(int group) {
|
||||
return switch (group) {
|
||||
case 0 -> collision0;
|
||||
case 1 -> collision1;
|
||||
case 2 -> collision2;
|
||||
default -> throw new IndexOutOfBoundsException(group);
|
||||
};
|
||||
}
|
||||
|
||||
public static String collisionProp(int group) {
|
||||
return "collision" + group;
|
||||
}
|
||||
|
||||
public byte[] header() {
|
||||
return header;
|
||||
}
|
||||
|
||||
public int vertexCount() {
|
||||
return vertices.length / VERTEX_STRIDE;
|
||||
}
|
||||
|
||||
public int faceCount() {
|
||||
return faces.length / FACE_STRIDE;
|
||||
}
|
||||
|
||||
public int collisionCount(int group) {
|
||||
return collision(group).length / COLLISION_SIZE;
|
||||
}
|
||||
|
||||
public int collisionCount() {
|
||||
return collisionCount(0) + collisionCount(1) + collisionCount(2);
|
||||
}
|
||||
|
||||
public double vx(int v) {
|
||||
return vertices[v * VERTEX_STRIDE];
|
||||
}
|
||||
|
||||
public double vy(int v) {
|
||||
return vertices[v * VERTEX_STRIDE + 1];
|
||||
}
|
||||
|
||||
public double vz(int v) {
|
||||
return vertices[v * VERTEX_STRIDE + 2];
|
||||
}
|
||||
|
||||
public int faceFlags(int f) {
|
||||
return faces[f * FACE_STRIDE];
|
||||
}
|
||||
|
||||
public int faceVertex(int f, int k) {
|
||||
return faces[f * FACE_STRIDE + 1 + k];
|
||||
}
|
||||
|
||||
public int faceTexture(int f) {
|
||||
return faces[f * FACE_STRIDE + 4];
|
||||
}
|
||||
|
||||
/** 32-byte record k of a group (copy). */
|
||||
public byte[] collisionRecord(int group, int k) {
|
||||
byte[] g = collision(group);
|
||||
byte[] r = new byte[COLLISION_SIZE];
|
||||
System.arraycopy(g, k * COLLISION_SIZE, r, 0, COLLISION_SIZE);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Decoded collision polygon in local export space (planes; corners via {@link Collision#polygon}). */
|
||||
public Collision.Decoded collisionPolygon(int group, int k) {
|
||||
return Collision.decode(collisionRecord(group, k));
|
||||
}
|
||||
|
||||
/** A collision polygon placed by a tile transform: corners and face normal in world export space. */
|
||||
public record PlacedPolygon(int group, int index, int material, double[][] corners, double[] normal) {}
|
||||
|
||||
/** Collision polygons with at least 3 corners, placed by xf (see {@link Tile#worldCollisionPolygons}). */
|
||||
public List<PlacedPolygon> collisionPolygons(Geometry.TileXform xf) {
|
||||
List<PlacedPolygon> out = new ArrayList<>();
|
||||
for (int g = 0; g < 3; g++) {
|
||||
for (int k = 0; k < collisionCount(g); k++) {
|
||||
Collision.Decoded d = collisionPolygon(g, k);
|
||||
double[][] local = Collision.polygon(d);
|
||||
if (local.length < 3) {
|
||||
continue;
|
||||
}
|
||||
double[][] w = new double[local.length][];
|
||||
for (int i = 0; i < local.length; i++) {
|
||||
w[i] = xf.point(local[i][0], local[i][1], local[i][2]);
|
||||
}
|
||||
double[] n = xf.normal(d.plane()[0], d.plane()[1], d.plane()[2]);
|
||||
out.add(new PlacedPolygon(g, k, d.mat(), w, n));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int collisionMaterial(int group, int k) {
|
||||
return collision(group)[k * COLLISION_SIZE + 1] & 0xff;
|
||||
}
|
||||
|
||||
/** Whether any collision polygon has this material. */
|
||||
public boolean hasCollisionMaterial(int material) {
|
||||
for (int g = 0; g < 3; g++) {
|
||||
for (int k = 0; k < collisionCount(g); k++) {
|
||||
if (collisionMaterial(g, k) == material) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Copy of a collision group with the material of record k replaced (for {@code SetProp}). */
|
||||
public byte[] withCollisionMaterial(int group, int k, int material) {
|
||||
byte[] g = collision(group).clone();
|
||||
g[k * COLLISION_SIZE + 1] = (byte) material;
|
||||
return g;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
|
||||
/** Adds a detached node (from {@link Scene#create} / {@link Scene#createBrush}) at an index (-1 = end). */
|
||||
public final class AddNode implements Command {
|
||||
private final Node node;
|
||||
private final int index;
|
||||
|
||||
public AddNode(Node node, int index) {
|
||||
this.node = node;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public AddNode(Node node) {
|
||||
this(node, -1);
|
||||
}
|
||||
|
||||
public Node node() {
|
||||
return node;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "Add " + node.kind().label().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {
|
||||
s.add(node, index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {
|
||||
s.remove(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Several commands as one undo step (reverted in reverse order). */
|
||||
public final class Batch implements Command {
|
||||
private final String label;
|
||||
private final List<Command> commands;
|
||||
private final Object mergeKey;
|
||||
|
||||
public Batch(String label, List<Command> commands) {
|
||||
this(label, commands, null);
|
||||
}
|
||||
|
||||
/** With a merge key, a following batch of the same shape (same targets) merges into this one. */
|
||||
public Batch(String label, List<Command> commands, Object mergeKey) {
|
||||
this.label = label;
|
||||
this.commands = List.copyOf(commands);
|
||||
this.mergeKey = mergeKey;
|
||||
}
|
||||
|
||||
public List<Command> commands() {
|
||||
return commands;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {
|
||||
for (Command c : commands) {
|
||||
c.apply(s);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {
|
||||
for (int i = commands.size() - 1; i >= 0; i--) {
|
||||
commands.get(i).revert(s);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object mergeKey() {
|
||||
return mergeKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Command mergeWith(Command next) {
|
||||
if (!(next instanceof Batch b)
|
||||
|| mergeKey == null
|
||||
|| !mergeKey.equals(b.mergeKey)
|
||||
|| b.commands.size() != commands.size()) {
|
||||
return null;
|
||||
}
|
||||
List<Command> out = new ArrayList<>();
|
||||
for (int i = 0; i < commands.size(); i++) {
|
||||
Command m = commands.get(i).mergeWith(b.commands.get(i));
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
out.add(m);
|
||||
}
|
||||
return new Batch(label, out, mergeKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
|
||||
/** An undoable change of a {@link Scene}. Commands fire the scene's events through {@link Scene}. */
|
||||
public interface Command {
|
||||
String label();
|
||||
|
||||
void apply(Scene s);
|
||||
|
||||
void revert(Scene s);
|
||||
|
||||
/**
|
||||
* Key for merging consecutive commands (e.g. one drag gesture); {@code null} = never merge.
|
||||
* Commands with equal non-null keys are offered to {@link #mergeWith}.
|
||||
*/
|
||||
default Object mergeKey() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** This command followed by {@code next} as one command, or {@code null} if not mergeable. */
|
||||
default Command mergeWith(Command next) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.BrushKind;
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.StratCatalog;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Ready-made commands for common edits. Positions are in game space cells (x east, y up, z south),
|
||||
* angles in degrees, unless noted.
|
||||
*/
|
||||
public final class Edits {
|
||||
private Edits() {}
|
||||
|
||||
public static int fixed(double cells) {
|
||||
return (int) Math.round(cells * 4096);
|
||||
}
|
||||
|
||||
public static int angleUnits(double degrees) {
|
||||
return (int) Math.round(((degrees % 360 + 360) % 360) / 360 * 4096) & 0xfff;
|
||||
}
|
||||
|
||||
/** Several property changes as one step; values map: node → (prop → value). */
|
||||
public static Command set(String label, Map<Node, Map<String, Object>> values, Object mergeKey) {
|
||||
List<Command> l = flatten(values, mergeKey);
|
||||
return l.size() == 1 && mergeKey == null ? l.getFirst() : new Batch(label, l, mergeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves nodes by (dx, dy, dz) cells in game space: strats, waypoints, tiles, brushes, zone
|
||||
* targets, point lights. For a drag, pass the same mergeKey for every step of the gesture.
|
||||
*/
|
||||
public static Command translate(
|
||||
Collection<? extends Node> nodes, double dx, double dy, double dz, Object mergeKey) {
|
||||
Map<Node, Map<String, Object>> m = new IdentityHashMap<>();
|
||||
for (Node n : nodes) {
|
||||
Map<String, Object> v = new LinkedHashMap<>();
|
||||
switch (n) {
|
||||
case Strat s -> {
|
||||
v.put("x", s.x() + fixed(dx));
|
||||
v.put("y", s.y() + fixed(dy));
|
||||
v.put("z", s.z() + fixed(dz));
|
||||
}
|
||||
case PathNode p -> {
|
||||
v.put("x", p.x() + fixed(dx));
|
||||
v.put("y", p.y() + fixed(dy));
|
||||
v.put("z", p.z() + fixed(dz));
|
||||
}
|
||||
case Tile t -> {
|
||||
v.put("x", (double) (float) (t.x() + dx));
|
||||
v.put("y", (double) (float) (t.y() + dy));
|
||||
v.put("z", (double) (float) (t.z() - dz));
|
||||
}
|
||||
case Brush b -> v.putAll(b.translated(dx, dy, dz));
|
||||
case EnvZone z -> {
|
||||
v.put("x", z.field(3) + fixed(dx));
|
||||
v.put("y", z.field(4) + fixed(dy));
|
||||
v.put("z", z.field(5) + fixed(dz));
|
||||
}
|
||||
case Light l -> {
|
||||
if (l.type() != Light.DIRECTIONAL) {
|
||||
v.put("x", l.x() + dx);
|
||||
v.put("y", l.y() + dy);
|
||||
v.put("z", l.z() + dz);
|
||||
}
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
if (!v.isEmpty()) {
|
||||
m.put(n, v);
|
||||
}
|
||||
}
|
||||
return new Batch("Move", flatten(m, mergeKey), mergeKey);
|
||||
}
|
||||
|
||||
private static List<Command> flatten(Map<Node, Map<String, Object>> m, Object mergeKey) {
|
||||
List<Command> l = new ArrayList<>();
|
||||
m.forEach((n, vals) -> vals.forEach((p, v) -> l.add(SetProp.of(n, n.prop(p), v, mergeKey))));
|
||||
return l;
|
||||
}
|
||||
|
||||
/** Sets the yaw of a strat / waypoint (degrees, 0 = facing +z, 90 = +x) or a tile (quarter turns etc.). */
|
||||
public static Command rotateTo(Node n, double degrees, Object mergeKey) {
|
||||
return SetProp.of(n, n.prop(n instanceof Tile ? "rot" : "ry"), angleUnits(degrees), mergeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a new object of a catalog type: type fields and default parameters from the
|
||||
* prototype, its waypoint chain copied relative to the new position ("relative" mode of
|
||||
* levelbuild, without snapping to the ground).
|
||||
*/
|
||||
public static Command placeStrat(Scene s, String type, int variant, double x, double y, double z, Double yawDeg) {
|
||||
StratCatalog.Type t = s.catalog().type(type);
|
||||
if (t == null) {
|
||||
throw new IllegalArgumentException("object type '" + type + "' is not placeable in this level");
|
||||
}
|
||||
StratCatalog.Variant v = t.variant(variant);
|
||||
Strat st = (Strat) s.create(NodeKind.STRAT);
|
||||
List<Command> cmds = new ArrayList<>();
|
||||
int fx = fixed(x);
|
||||
int fy = fixed(y);
|
||||
int fz = fixed(z);
|
||||
List<PathNode> chain = new ArrayList<>();
|
||||
for (int[] c : v.chain()) {
|
||||
PathNode p = (PathNode) s.create(NodeKind.PATH_NODE);
|
||||
p.setRaw("x", fx + c[0]);
|
||||
p.setRaw("y", fy + c[1]);
|
||||
p.setRaw("z", fz + c[2]);
|
||||
p.setRaw("rx", c[3]);
|
||||
p.setRaw("ry", c[4]);
|
||||
p.setRaw("rz", c[5]);
|
||||
p.setRaw("unk1", c[6]);
|
||||
p.setRaw("unk2", c[7]);
|
||||
p.setRaw("unk3", c[8]);
|
||||
if (!chain.isEmpty()) {
|
||||
chain.getLast().setRaw("next", p);
|
||||
}
|
||||
chain.add(p);
|
||||
cmds.add(new AddNode(p));
|
||||
}
|
||||
st.setRaw("code", v.code());
|
||||
st.setRaw("nVars", v.nVars());
|
||||
st.setRaw("slots", v.slots());
|
||||
st.setRaw("typeFlags", v.typeFlags());
|
||||
st.setRaw("extra", v.extra());
|
||||
st.setRaw("flags", v.flags() & ~Strat.FLAG_HIDDEN);
|
||||
st.setRaw("rx", v.rx());
|
||||
st.setRaw("ry", yawDeg == null ? v.ry() : angleUnits(yawDeg));
|
||||
st.setRaw("rz", v.rz());
|
||||
st.setRaw("x", fx);
|
||||
st.setRaw("y", fy);
|
||||
st.setRaw("z", fz);
|
||||
st.setRaw("params", v.params().clone());
|
||||
st.setRaw("waypoint", chain.isEmpty() ? null : chain.getFirst());
|
||||
st.setTypeName(type);
|
||||
cmds.add(new AddNode(st));
|
||||
return new Batch("Add " + type, cmds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes nodes (and, for strats, the waypoint chains only they use). Tile models still used
|
||||
* by tiles are refused.
|
||||
*/
|
||||
public static Command delete(Scene s, Collection<? extends Node> nodes) {
|
||||
Set<Node> del = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
del.addAll(nodes);
|
||||
for (Node n : nodes) {
|
||||
if (n instanceof Strat st && st.waypoint() != null) {
|
||||
List<PathNode> ch = s.chain(st.waypoint());
|
||||
boolean shared = s.strats().stream()
|
||||
.anyMatch(o -> o != st
|
||||
&& !del.contains(o)
|
||||
&& o.waypoint() != null
|
||||
&& ch.contains(o.waypoint()))
|
||||
|| s.pathNodes().stream().anyMatch(p -> !ch.contains(p) && ch.contains(p.next()));
|
||||
if (!shared) {
|
||||
del.addAll(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
// users before the nodes they reference
|
||||
List<Node> order = new ArrayList<>(del);
|
||||
order.sort(Comparator.comparingInt(n ->
|
||||
n instanceof Strat ? 0 : n instanceof Tile || n instanceof Brush ? 1 : n instanceof PathNode ? 2 : 3));
|
||||
return new DeferredRemove(s, order);
|
||||
}
|
||||
|
||||
/** Removes a set of nodes, computing reference unlinks at apply time (so earlier removals count). */
|
||||
private static final class DeferredRemove implements Command {
|
||||
private final List<Node> nodes;
|
||||
private final List<RemoveNode> done = new ArrayList<>();
|
||||
|
||||
DeferredRemove(Scene s, List<Node> nodes) {
|
||||
this.nodes = nodes;
|
||||
for (Node n : nodes) {
|
||||
for (Map.Entry<Node, Prop> r : s.referencesTo(n)) {
|
||||
if (!r.getValue().nullable() && !nodes.contains(r.getKey())) {
|
||||
throw new IllegalStateException(
|
||||
n.id() + " is still used by " + r.getKey().id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return nodes.size() == 1
|
||||
? "Delete " + nodes.getFirst().kind().label().toLowerCase(Locale.ROOT)
|
||||
: "Delete " + nodes.size() + " items";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {
|
||||
done.clear();
|
||||
for (Node n : nodes) {
|
||||
RemoveNode r = new RemoveNode(s, n);
|
||||
r.apply(s);
|
||||
done.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {
|
||||
for (int i = done.size() - 1; i >= 0; i--) {
|
||||
done.get(i).revert(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** New brush with parameters (missing ones keep their defaults). */
|
||||
public static Command addBrush(Scene s, BrushKind kind, Map<String, Object> params) {
|
||||
Brush b = s.createBrush(kind);
|
||||
params.forEach(b::setRaw);
|
||||
return new AddNode(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Undo/redo stack of a scene. {@link #execute} applies a command; consecutive commands with equal
|
||||
* {@link Command#mergeKey()} are merged into one step (drags). {@link #begin}/{@link #end} group
|
||||
* everything executed in between into one {@link Batch}.
|
||||
*/
|
||||
public final class History {
|
||||
private final Scene scene;
|
||||
private final Deque<Command> undo = new ArrayDeque<>();
|
||||
private final Deque<Command> redo = new ArrayDeque<>();
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
private final int limit;
|
||||
private List<Command> group;
|
||||
private String groupLabel;
|
||||
private int groupDepth;
|
||||
private Command savedAt;
|
||||
private boolean savedAtEmpty = true;
|
||||
/** Stands for a saved state that is no longer on the stack (dropped by the limit or by clear). */
|
||||
private static final Command UNREACHABLE = new Batch("saved state", List.of());
|
||||
|
||||
public History(Scene scene) {
|
||||
this(scene, 500);
|
||||
}
|
||||
|
||||
public History(Scene scene, int limit) {
|
||||
this.scene = scene;
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public Scene scene() {
|
||||
return scene;
|
||||
}
|
||||
|
||||
public void execute(Command c) {
|
||||
c.apply(scene);
|
||||
if (group != null) {
|
||||
group.add(c);
|
||||
return;
|
||||
}
|
||||
push(c);
|
||||
}
|
||||
|
||||
private void push(Command c) {
|
||||
redo.clear();
|
||||
Command top = undo.peek();
|
||||
if (top != null && c.mergeKey() != null && c.mergeKey().equals(top.mergeKey()) && top != savedAt) {
|
||||
Command m = top.mergeWith(c);
|
||||
if (m != null) {
|
||||
undo.pop();
|
||||
undo.push(m);
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
undo.push(c);
|
||||
while (undo.size() > limit) {
|
||||
undo.removeLast();
|
||||
if (savedAtEmpty) {
|
||||
savedAtEmpty = false;
|
||||
savedAt = UNREACHABLE;
|
||||
}
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Starts a group; nested calls join the outer group. */
|
||||
public void begin(String label) {
|
||||
if (groupDepth++ == 0) {
|
||||
group = new ArrayList<>();
|
||||
groupLabel = label;
|
||||
}
|
||||
}
|
||||
|
||||
public void end() {
|
||||
if (groupDepth == 0) {
|
||||
throw new IllegalStateException("end() without begin()");
|
||||
}
|
||||
if (--groupDepth == 0) {
|
||||
List<Command> g = group;
|
||||
group = null;
|
||||
if (!g.isEmpty()) {
|
||||
push(g.size() == 1 ? g.getFirst() : new Batch(groupLabel, g));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canUndo() {
|
||||
return !undo.isEmpty();
|
||||
}
|
||||
|
||||
public boolean canRedo() {
|
||||
return !redo.isEmpty();
|
||||
}
|
||||
|
||||
public String undoLabel() {
|
||||
return undo.isEmpty() ? "" : undo.peek().label();
|
||||
}
|
||||
|
||||
public String redoLabel() {
|
||||
return redo.isEmpty() ? "" : redo.peek().label();
|
||||
}
|
||||
|
||||
/** Labels of the undo steps, most recent first. */
|
||||
public List<String> undoLabels() {
|
||||
return undo.stream().map(Command::label).toList();
|
||||
}
|
||||
|
||||
/** Labels of the redo steps, next first. */
|
||||
public List<String> redoLabels() {
|
||||
return redo.stream().map(Command::label).toList();
|
||||
}
|
||||
|
||||
public void undo() {
|
||||
if (undo.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Command c = undo.pop();
|
||||
c.revert(scene);
|
||||
redo.push(c);
|
||||
changed();
|
||||
}
|
||||
|
||||
public void redo() {
|
||||
if (redo.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Command c = redo.pop();
|
||||
c.apply(scene);
|
||||
undo.push(c);
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Drops all steps. A dirty history stays dirty (the saved state can no longer be reached). */
|
||||
public void clear() {
|
||||
boolean dirty = isDirty();
|
||||
undo.clear();
|
||||
redo.clear();
|
||||
savedAtEmpty = !dirty;
|
||||
savedAt = dirty ? UNREACHABLE : null;
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Remembers the current state as saved (see {@link #isDirty}). */
|
||||
public void markSaved() {
|
||||
savedAt = undo.peek();
|
||||
savedAtEmpty = undo.isEmpty();
|
||||
changed();
|
||||
}
|
||||
|
||||
public boolean isDirty() {
|
||||
return savedAtEmpty ? !undo.isEmpty() : undo.peek() != savedAt;
|
||||
}
|
||||
|
||||
/** Called after every execute/undo/redo/save mark (update menus, title). */
|
||||
public void addListener(Runnable r) {
|
||||
listeners.add(r);
|
||||
}
|
||||
|
||||
public void removeListener(Runnable r) {
|
||||
listeners.remove(r);
|
||||
}
|
||||
|
||||
private void changed() {
|
||||
for (Runnable r : listeners) {
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Removes a node. Optional references to it (a strat's waypoint, a node's next, a tile's swap
|
||||
* model) are cleared and restored on undo; a required reference (a tile's model) refuses the
|
||||
* removal with {@link IllegalStateException} when the command is created.
|
||||
*/
|
||||
public final class RemoveNode implements Command {
|
||||
private final Node node;
|
||||
private final List<SetProp> unlink = new ArrayList<>();
|
||||
private int index = -1;
|
||||
|
||||
public RemoveNode(Scene s, Node node) {
|
||||
this.node = node;
|
||||
for (Map.Entry<Node, Prop> r : s.referencesTo(node)) {
|
||||
if (!r.getValue().nullable()) {
|
||||
throw new IllegalStateException(node.id() + " is still used by "
|
||||
+ r.getKey().id() + " (" + r.getValue().label() + ")");
|
||||
}
|
||||
unlink.add(SetProp.of(r.getKey(), r.getValue(), null, null));
|
||||
}
|
||||
}
|
||||
|
||||
public Node node() {
|
||||
return node;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "Delete " + node.kind().label().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {
|
||||
for (SetProp p : unlink) {
|
||||
p.apply(s);
|
||||
}
|
||||
index = s.remove(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {
|
||||
s.add(node, index);
|
||||
for (int i = unlink.size() - 1; i >= 0; i--) {
|
||||
unlink.get(i).revert(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Sets one property. With a merge key, consecutive changes of the same node/property merge. */
|
||||
public final class SetProp implements Command {
|
||||
private final Node node;
|
||||
private final Prop prop;
|
||||
private final Object oldValue;
|
||||
private final Object newValue;
|
||||
private final Object mergeKey;
|
||||
|
||||
private SetProp(Node node, Prop prop, Object oldValue, Object newValue, Object mergeKey) {
|
||||
this.node = node;
|
||||
this.prop = prop;
|
||||
this.oldValue = oldValue;
|
||||
this.newValue = newValue;
|
||||
this.mergeKey = mergeKey;
|
||||
}
|
||||
|
||||
public static SetProp of(Node n, String prop, Object value) {
|
||||
return of(n, n.prop(prop), value, null);
|
||||
}
|
||||
|
||||
/** mergeKey: e.g. a drag gesture object, so all moves of one drag become one undo step. */
|
||||
public static SetProp of(Node n, Prop p, Object value, Object mergeKey) {
|
||||
return new SetProp(n, p, Values.copy(p.get(n)), Values.copy(value), mergeKey);
|
||||
}
|
||||
|
||||
public Node node() {
|
||||
return node;
|
||||
}
|
||||
|
||||
public Prop prop() {
|
||||
return prop;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "Change " + prop.label();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {
|
||||
s.set(node, prop, Values.copy(newValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {
|
||||
s.set(node, prop, Values.copy(oldValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object mergeKey() {
|
||||
return mergeKey == null ? null : java.util.List.of(mergeKey, node, prop.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Command mergeWith(Command next) {
|
||||
if (next instanceof SetProp o && o.node == node && o.prop == prop && Objects.equals(mergeKey, o.mergeKey)) {
|
||||
return new SetProp(node, prop, oldValue, o.newValue, mergeKey);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eliasmueller.llamamod.editor.model.cmd;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Copy/compare helpers for property values (arrays are copied so commands own their values). */
|
||||
public final class Values {
|
||||
private Values() {}
|
||||
|
||||
public static Object copy(Object v) {
|
||||
if (v instanceof int[] a) {
|
||||
return a.clone();
|
||||
}
|
||||
if (v instanceof double[] a) {
|
||||
return a.clone();
|
||||
}
|
||||
if (v instanceof byte[] a) {
|
||||
return a.clone();
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
public static boolean same(Object a, Object b) {
|
||||
return Objects.deepEquals(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import static org.lwjgl.opengl.GL33C.*;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
import org.lwjgl.system.MemoryUtil;
|
||||
|
||||
/**
|
||||
* GPU buffers for a group of triangles (sorted into 6 ranges: blend class × double-sided) plus
|
||||
* overlay lines and translucent overlay triangles. Built on the CPU with {@link Builder}.
|
||||
*/
|
||||
final class Batch {
|
||||
static final int STRIDE = 36;
|
||||
static final int LINE_STRIDE = 16;
|
||||
static final int RANGES = 6;
|
||||
|
||||
int vao;
|
||||
int vbo;
|
||||
int lineVao;
|
||||
int lineVbo;
|
||||
final int[] first = new int[RANGES];
|
||||
final int[] count = new int[RANGES];
|
||||
int lineVerts;
|
||||
int fillVerts;
|
||||
int triangles;
|
||||
/** Local bounds of a single-instance batch (mesh + overlay), else unused. */
|
||||
float[] box;
|
||||
|
||||
static int range(int blend, boolean doubleSided) {
|
||||
return blend * 2 + (doubleSided ? 1 : 0);
|
||||
}
|
||||
|
||||
void delete() {
|
||||
if (vao != 0) {
|
||||
glDeleteVertexArrays(vao);
|
||||
glDeleteBuffers(vbo);
|
||||
}
|
||||
if (lineVao != 0) {
|
||||
glDeleteVertexArrays(lineVao);
|
||||
glDeleteBuffers(lineVbo);
|
||||
}
|
||||
vao = vbo = lineVao = lineVbo = 0;
|
||||
}
|
||||
|
||||
boolean empty() {
|
||||
return triangles == 0 && lineVerts == 0 && fillVerts == 0;
|
||||
}
|
||||
|
||||
void drawRange(int r) {
|
||||
if (count[r] > 0) {
|
||||
glBindVertexArray(vao);
|
||||
glDrawArrays(GL_TRIANGLES, first[r], count[r]);
|
||||
}
|
||||
}
|
||||
|
||||
void drawLines() {
|
||||
if (lineVerts > 0) {
|
||||
glBindVertexArray(lineVao);
|
||||
glDrawArrays(GL_LINES, 0, lineVerts);
|
||||
}
|
||||
}
|
||||
|
||||
void drawFill() {
|
||||
if (fillVerts > 0) {
|
||||
glBindVertexArray(lineVao);
|
||||
glDrawArrays(GL_TRIANGLES, lineVerts, fillVerts);
|
||||
}
|
||||
}
|
||||
|
||||
/** Growable direct buffer. */
|
||||
static final class Buf {
|
||||
ByteBuffer b = MemoryUtil.memAlloc(1 << 14);
|
||||
|
||||
void ensure(int n) {
|
||||
if (b.remaining() < n) {
|
||||
b = MemoryUtil.memRealloc(b, Math.max(b.capacity() * 2, b.position() + n));
|
||||
}
|
||||
}
|
||||
|
||||
void free() {
|
||||
MemoryUtil.memFree(b);
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects instances (optionally transformed) and uploads them as one batch. */
|
||||
static final class Builder {
|
||||
private final Buf[] tri = new Buf[RANGES];
|
||||
private final Buf lines = new Buf();
|
||||
private final Buf fill = new Buf();
|
||||
private final int whiteLayer;
|
||||
private final int[] pageW;
|
||||
private final float arraySize;
|
||||
|
||||
Builder(int whiteLayer, int[] pageW, float arraySize) {
|
||||
for (int i = 0; i < RANGES; i++) {
|
||||
tri[i] = new Buf();
|
||||
}
|
||||
this.whiteLayer = whiteLayer;
|
||||
this.pageW = pageW;
|
||||
this.arraySize = arraySize;
|
||||
}
|
||||
|
||||
/** Adds an instance; xform {@code null} keeps local coordinates. */
|
||||
void add(RenderInstance inst, Matrix4f xform, boolean withOverlay) {
|
||||
RenderMesh m = inst.mesh;
|
||||
int id = inst.id;
|
||||
int[] vc = inst.vertexColors;
|
||||
Vector3f p = new Vector3f();
|
||||
Vector3f n = new Vector3f();
|
||||
int nt = m.triangleCount();
|
||||
for (int t = 0; t < nt; t++) {
|
||||
int fl = m.triFlags[t];
|
||||
Buf b = tri[range(m.blend[t], (fl & RenderMesh.DOUBLE_SIDED) != 0)];
|
||||
b.ensure(3 * STRIDE);
|
||||
int tc = m.triColors[t];
|
||||
int page = m.pages[t];
|
||||
int layer = page < 0 || page >= pageW.length ? whiteLayer : page;
|
||||
float uvScale = page < 0 || page >= pageW.length ? 0 : pageW[page] / arraySize;
|
||||
for (int k = 0; k < 3; k++) {
|
||||
int v = m.triangles[3 * t + k];
|
||||
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]);
|
||||
n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]);
|
||||
if (xform != null) {
|
||||
xform.transformPosition(p);
|
||||
xform.transformDirection(n);
|
||||
}
|
||||
if (n.lengthSquared() > 0) {
|
||||
n.normalize();
|
||||
}
|
||||
ByteBuffer bb = b.b;
|
||||
bb.putFloat(p.x).putFloat(p.y).putFloat(p.z);
|
||||
bb.put(snorm(n.x)).put(snorm(n.y)).put(snorm(n.z)).put((byte) 0);
|
||||
bb.putFloat(m.uvs[6 * t + 2 * k] * uvScale).putFloat(m.uvs[6 * t + 2 * k + 1] * uvScale);
|
||||
int c = vc != null && (fl & RenderMesh.NO_VERTEX_COLOR) == 0 && v < vc.length
|
||||
? doubled(vc[v])
|
||||
: 0xffffffff;
|
||||
bb.putInt(mul(c, tc));
|
||||
bb.putInt(id);
|
||||
bb.putShort((short) layer).putShort((short) fl);
|
||||
}
|
||||
}
|
||||
if (withOverlay && m.overlay != null) {
|
||||
addOverlay(m.overlay, xform);
|
||||
}
|
||||
}
|
||||
|
||||
void addOverlay(Overlay o, Matrix4f xform) {
|
||||
Vector3f p = new Vector3f();
|
||||
float[] l = o.lines();
|
||||
int[] lc = o.lineColors();
|
||||
lines.ensure(o.lineCount() * 2 * LINE_STRIDE);
|
||||
for (int i = 0; i < o.lineCount(); i++) {
|
||||
for (int k = 0; k < 2; k++) {
|
||||
p.set(l[6 * i + 3 * k], l[6 * i + 3 * k + 1], l[6 * i + 3 * k + 2]);
|
||||
if (xform != null) {
|
||||
xform.transformPosition(p);
|
||||
}
|
||||
lines.b.putFloat(p.x).putFloat(p.y).putFloat(p.z).putInt(rgbaToAbgr(lc[i]));
|
||||
}
|
||||
}
|
||||
float[] tr = o.triangles();
|
||||
int[] tcol = o.triangleColors();
|
||||
fill.ensure(o.triangleCount() * 3 * LINE_STRIDE);
|
||||
for (int i = 0; i < o.triangleCount(); i++) {
|
||||
for (int k = 0; k < 3; k++) {
|
||||
p.set(tr[9 * i + 3 * k], tr[9 * i + 3 * k + 1], tr[9 * i + 3 * k + 2]);
|
||||
if (xform != null) {
|
||||
xform.transformPosition(p);
|
||||
}
|
||||
fill.b.putFloat(p.x).putFloat(p.y).putFloat(p.z).putInt(rgbaToAbgr(tcol[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads into target (existing buffers are replaced) and frees the CPU memory. */
|
||||
Batch upload(Batch target) {
|
||||
Batch g = target != null ? target : new Batch();
|
||||
g.delete();
|
||||
int total = 0;
|
||||
for (int r = 0; r < RANGES; r++) {
|
||||
g.first[r] = total;
|
||||
g.count[r] = tri[r].b.position() / STRIDE;
|
||||
total += g.count[r];
|
||||
}
|
||||
g.triangles = total / 3;
|
||||
if (total > 0) {
|
||||
ByteBuffer all = MemoryUtil.memAlloc(total * STRIDE);
|
||||
for (Buf b : tri) {
|
||||
b.b.flip();
|
||||
all.put(b.b);
|
||||
}
|
||||
all.flip();
|
||||
g.vao = glGenVertexArrays();
|
||||
g.vbo = glGenBuffers();
|
||||
glBindVertexArray(g.vao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, g.vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
|
||||
MemoryUtil.memFree(all);
|
||||
meshAttribs();
|
||||
}
|
||||
g.lineVerts = lines.b.position() / LINE_STRIDE;
|
||||
g.fillVerts = fill.b.position() / LINE_STRIDE;
|
||||
if (g.lineVerts + g.fillVerts > 0) {
|
||||
ByteBuffer all = MemoryUtil.memAlloc((g.lineVerts + g.fillVerts) * LINE_STRIDE);
|
||||
all.put(lines.b.flip()).put(fill.b.flip()).flip();
|
||||
g.lineVao = glGenVertexArrays();
|
||||
g.lineVbo = glGenBuffers();
|
||||
glBindVertexArray(g.lineVao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, g.lineVbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
|
||||
MemoryUtil.memFree(all);
|
||||
lineAttribs();
|
||||
}
|
||||
glBindVertexArray(0);
|
||||
free();
|
||||
return g;
|
||||
}
|
||||
|
||||
void free() {
|
||||
for (Buf b : tri) {
|
||||
b.free();
|
||||
}
|
||||
lines.free();
|
||||
fill.free();
|
||||
}
|
||||
}
|
||||
|
||||
static void meshAttribs() {
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, false, STRIDE, 0);
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 3, GL_BYTE, true, STRIDE, 12);
|
||||
glEnableVertexAttribArray(2);
|
||||
glVertexAttribPointer(2, 2, GL_FLOAT, false, STRIDE, 16);
|
||||
glEnableVertexAttribArray(3);
|
||||
glVertexAttribPointer(3, 4, GL_UNSIGNED_BYTE, true, STRIDE, 24);
|
||||
glEnableVertexAttribArray(4);
|
||||
glVertexAttribPointer(4, 4, GL_UNSIGNED_BYTE, true, STRIDE, 28);
|
||||
glEnableVertexAttribArray(5);
|
||||
glVertexAttribIPointer(5, 2, GL_SHORT, STRIDE, 32);
|
||||
}
|
||||
|
||||
static void lineAttribs() {
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, false, LINE_STRIDE, 0);
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, true, LINE_STRIDE, 12);
|
||||
}
|
||||
|
||||
static byte snorm(float f) {
|
||||
return (byte) Math.round(Math.max(-1, Math.min(1, f)) * 127);
|
||||
}
|
||||
|
||||
/** Game vertex colour (0x80 = 1.0) to 0..255, doubled with clamp. */
|
||||
static int doubled(int rgba) {
|
||||
int r = Math.min(255, ((rgba >>> 24) & 0xff) * 2);
|
||||
int g = Math.min(255, ((rgba >>> 16) & 0xff) * 2);
|
||||
int b = Math.min(255, ((rgba >>> 8) & 0xff) * 2);
|
||||
return r << 24 | g << 16 | b << 8 | (rgba & 0xff);
|
||||
}
|
||||
|
||||
static int mul(int a, int b) {
|
||||
if (b == 0xffffffff) {
|
||||
return rgbaToAbgr(a);
|
||||
}
|
||||
int r = ((a >>> 24) & 0xff) * ((b >>> 24) & 0xff) / 255;
|
||||
int g = ((a >>> 16) & 0xff) * ((b >>> 16) & 0xff) / 255;
|
||||
int bl = ((a >>> 8) & 0xff) * ((b >>> 8) & 0xff) / 255;
|
||||
int al = (a & 0xff) * (b & 0xff) / 255;
|
||||
return rgbaToAbgr(r << 24 | g << 16 | bl << 8 | al);
|
||||
}
|
||||
|
||||
/** 0xRRGGBBAA to the little-endian int that stores bytes R, G, B, A in memory order. */
|
||||
static int rgbaToAbgr(int c) {
|
||||
return Integer.reverseBytes(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
import org.joml.Vector4f;
|
||||
|
||||
/**
|
||||
* Editor camera: position + yaw/pitch, perspective or orthographic. Units are level cells (export
|
||||
* space). The view is <b>left-handed like the game's Direct3D renderer</b>: export coordinates are
|
||||
* drawn mirrored (screen x flipped) compared to a plain OpenGL view, so the picture matches the
|
||||
* game. Yaw and pitch equal the game camera's angles (0x5790a4/0x5790a0, 4096 = 2π): yaw 0 looks
|
||||
* along export -z (= game +z), positive yaw turns <b>left</b> (towards +x); pitch positive looks
|
||||
* up. Screen right = (-cos yaw, 0, -sin yaw) in export space (game: (-cos yaw, 0, sin yaw)).
|
||||
*/
|
||||
public final class Camera {
|
||||
public enum View {
|
||||
PERSPECTIVE,
|
||||
TOP,
|
||||
FRONT,
|
||||
SIDE
|
||||
}
|
||||
|
||||
public final Vector3f position = new Vector3f(0, 5, 10);
|
||||
public float yaw;
|
||||
public float pitch = -0.4f;
|
||||
public float fovY = (float) Math.toRadians(60);
|
||||
public float near = 0.03f;
|
||||
public float far = 600f;
|
||||
public boolean ortho;
|
||||
/** Visible half height in orthographic mode. */
|
||||
public float orthoSize = 10;
|
||||
/** Distance to the orbit pivot (also used for pan speed and ortho framing). */
|
||||
public float distance = 10;
|
||||
|
||||
public View view = View.PERSPECTIVE;
|
||||
|
||||
private int width = 1;
|
||||
private int height = 1;
|
||||
|
||||
public void viewportSize(int w, int h) {
|
||||
width = Math.max(1, w);
|
||||
height = Math.max(1, h);
|
||||
}
|
||||
|
||||
public int width() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public Vector3f forward() {
|
||||
float cp = (float) Math.cos(pitch);
|
||||
return new Vector3f((float) Math.sin(yaw) * cp, (float) Math.sin(pitch), -(float) Math.cos(yaw) * cp);
|
||||
}
|
||||
|
||||
/** World direction of screen right (horizontal). */
|
||||
public Vector3f right() {
|
||||
return new Vector3f(-(float) Math.cos(yaw), 0, -(float) Math.sin(yaw));
|
||||
}
|
||||
|
||||
/** World direction of screen up. */
|
||||
public Vector3f up() {
|
||||
return forward().cross(right()).normalize();
|
||||
}
|
||||
|
||||
public Vector3f pivot() {
|
||||
return forward().mul(distance).add(position);
|
||||
}
|
||||
|
||||
public Matrix4f viewMatrix() {
|
||||
Vector3f f = forward();
|
||||
// straight down: "screen up" is the yaw direction; straight up: its opposite (the same
|
||||
// limit as with world up, so pitching through the threshold never flips the picture)
|
||||
float sy = f.y < 0 ? 1 : -1;
|
||||
Vector3f up = Math.abs(f.y) > 0.999f
|
||||
? new Vector3f(sy * (float) Math.sin(yaw), 0, -sy * (float) Math.cos(yaw))
|
||||
: new Vector3f(0, 1, 0);
|
||||
Vector3f c = new Vector3f(position).add(f);
|
||||
// scale(-1, 1, 1): left-handed (Direct3D) view, see the class comment; flips triangle winding
|
||||
return new Matrix4f().scale(-1, 1, 1).lookAt(position, c, up);
|
||||
}
|
||||
|
||||
public Matrix4f projectionMatrix() {
|
||||
float a = width / (float) height;
|
||||
if (ortho) {
|
||||
return new Matrix4f().ortho(-orthoSize * a, orthoSize * a, -orthoSize, orthoSize, -far, far);
|
||||
}
|
||||
return new Matrix4f().perspective(fovY, a, near, far);
|
||||
}
|
||||
|
||||
public Matrix4f viewProjection() {
|
||||
return projectionMatrix().mul(viewMatrix());
|
||||
}
|
||||
|
||||
/** Pick ray through a pixel (origin, direction) in world space; y grows downwards. */
|
||||
public Vector3f[] ray(double px, double py) {
|
||||
Matrix4f inv = viewProjection().invert();
|
||||
float nx = (float) (2 * px / width - 1);
|
||||
float ny = (float) (1 - 2 * py / height);
|
||||
Vector4f a = new Vector4f(nx, ny, -1, 1).mul(inv);
|
||||
Vector4f b = new Vector4f(nx, ny, 1, 1).mul(inv);
|
||||
Vector3f o = new Vector3f(a.x / a.w, a.y / a.w, a.z / a.w);
|
||||
Vector3f e = new Vector3f(b.x / b.w, b.y / b.w, b.z / b.w);
|
||||
return new Vector3f[] {o, e.sub(o).normalize()};
|
||||
}
|
||||
|
||||
/** World point to pixel coordinates {x, y, depth}; depth > 1 or < -1 = clipped. */
|
||||
public Vector3f project(Vector3f p) {
|
||||
Vector4f v = new Vector4f(p, 1).mul(viewProjection());
|
||||
if (v.w <= 1e-6f && !ortho) {
|
||||
return new Vector3f(Float.NaN, Float.NaN, 2);
|
||||
}
|
||||
return new Vector3f((v.x / v.w + 1) * 0.5f * width, (1 - v.y / v.w) * 0.5f * height, v.z / v.w);
|
||||
}
|
||||
|
||||
/** World units per pixel at a point (for constant screen-size handles). */
|
||||
public float pixelSize(Vector3f p) {
|
||||
if (ortho) {
|
||||
return 2 * orthoSize / height;
|
||||
}
|
||||
float d = Math.max(near, new Vector3f(p).sub(position).dot(forward()));
|
||||
return (float) (2 * d * Math.tan(fovY / 2) / height);
|
||||
}
|
||||
|
||||
/** Places the camera so that the box fits the view, keeping the direction. */
|
||||
public void frame(float[] box) {
|
||||
Vector3f c = new Vector3f((box[0] + box[3]) / 2, (box[1] + box[4]) / 2, (box[2] + box[5]) / 2);
|
||||
Vector3f f = forward(), r = right(), u = up();
|
||||
float tanY = (float) Math.tan(fovY / 2), tanX = tanY * width / (float) height;
|
||||
float d = 0.5f, half = 0.3f;
|
||||
for (int k = 0; k < 8; k++) {
|
||||
Vector3f p = new Vector3f(box[(k & 1) != 0 ? 3 : 0], box[(k & 2) != 0 ? 4 : 1], box[(k & 4) != 0 ? 5 : 2])
|
||||
.sub(c);
|
||||
float x = Math.abs(p.dot(r)), y = Math.abs(p.dot(u)), z = p.dot(f);
|
||||
d = Math.max(d, Math.max(y / tanY, x / tanX) - z);
|
||||
half = Math.max(half, Math.max(y, x * height / (float) width));
|
||||
}
|
||||
distance = d * 1.08f;
|
||||
orthoSize = half * 1.08f;
|
||||
position.set(f.mul(-distance).add(c));
|
||||
}
|
||||
|
||||
/** Keeps the pivot and switches to a standard view (orthographic for top/front/side). */
|
||||
public void setView(View v) {
|
||||
Vector3f p = pivot();
|
||||
view = v;
|
||||
switch (v) {
|
||||
case TOP -> {
|
||||
// screen up = export +z (game -z): x to the right, grid rows (game z) downwards
|
||||
yaw = (float) Math.PI;
|
||||
pitch = (float) (-Math.PI / 2);
|
||||
ortho = true;
|
||||
}
|
||||
case FRONT -> {
|
||||
yaw = (float) Math.PI;
|
||||
pitch = 0;
|
||||
ortho = true;
|
||||
}
|
||||
case SIDE -> {
|
||||
yaw = (float) (-Math.PI / 2);
|
||||
pitch = 0;
|
||||
ortho = true;
|
||||
}
|
||||
case PERSPECTIVE -> ortho = false;
|
||||
}
|
||||
if (ortho) {
|
||||
orthoSize = Math.max(orthoSize, 0.5f);
|
||||
}
|
||||
position.set(forward().mul(-distance).add(p));
|
||||
}
|
||||
|
||||
public void copyFrom(Camera o) {
|
||||
position.set(o.position);
|
||||
yaw = o.yaw;
|
||||
pitch = o.pitch;
|
||||
ortho = o.ortho;
|
||||
orthoSize = o.orthoSize;
|
||||
distance = o.distance;
|
||||
view = o.view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.FrustumIntersection;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Matrix4fc;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Conservative view-frustum culling. Boxes are {minX, minY, minZ, maxX, maxY, maxZ}; an instance's
|
||||
* box covers every triangle vertex <b>and</b> its helper overlay (collision lines/fills), so nothing
|
||||
* that is drawn with an instance can be culled with it. The planes come from the current
|
||||
* projection × view (the left-handed view only swaps the left/right planes, the test is
|
||||
* orientation-independent).
|
||||
*/
|
||||
final class Culling {
|
||||
/** Absolute + relative box growth, so faces exactly on a plane survive float rounding. */
|
||||
static final float MARGIN = 0.01f;
|
||||
|
||||
static final float REL_MARGIN = 0.002f;
|
||||
|
||||
/** -Dllamamod.editor.noCull=true draws everything (diagnostics: culled and unculled frames must match). */
|
||||
static final boolean DISABLED = Boolean.getBoolean("llamamod.editor.noCull");
|
||||
|
||||
private final FrustumIntersection fr;
|
||||
|
||||
Culling(Matrix4fc viewProjection) {
|
||||
fr = new FrustumIntersection(viewProjection);
|
||||
}
|
||||
|
||||
/** False only when the box lies completely outside one frustum plane. */
|
||||
boolean visible(float[] b) {
|
||||
if (b == null || b[0] > b[3]) {
|
||||
return false;
|
||||
}
|
||||
if (DISABLED) {
|
||||
return true;
|
||||
}
|
||||
float m = MARGIN + REL_MARGIN * Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]));
|
||||
return fr.testAab(b[0] - m, b[1] - m, b[2] - m, b[3] + m, b[4] + m, b[5] + m);
|
||||
}
|
||||
|
||||
/** Local bounds of a mesh including its overlay; an empty box (min > max) when there is nothing. */
|
||||
static float[] localBox(RenderMesh m) {
|
||||
float[] b = emptyBox();
|
||||
float[] p = m.positions;
|
||||
for (int i = 0; i + 2 < p.length; i += 3) {
|
||||
grow(b, p[i], p[i + 1], p[i + 2]);
|
||||
}
|
||||
Overlay o = m.overlay;
|
||||
if (o != null) {
|
||||
float[] l = o.lines();
|
||||
for (int i = 0; i < o.lineCount() * 6; i += 3) {
|
||||
grow(b, l[i], l[i + 1], l[i + 2]);
|
||||
}
|
||||
float[] t = o.triangles();
|
||||
for (int i = 0; i < o.triangleCount() * 9; i += 3) {
|
||||
grow(b, t[i], t[i + 1], t[i + 2]);
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/** World bounds of an instance (mesh + overlay, all 8 corners transformed). */
|
||||
static float[] worldBox(RenderInstance i) {
|
||||
float[] l = localBox(i.mesh);
|
||||
return l[0] > l[3] ? l : transform(l, i.transform);
|
||||
}
|
||||
|
||||
static float[] transform(float[] b, Matrix4f m) {
|
||||
float[] o = emptyBox();
|
||||
Vector3f p = new Vector3f();
|
||||
for (int k = 0; k < 8; k++) {
|
||||
p.set(b[(k & 1) != 0 ? 3 : 0], b[(k & 2) != 0 ? 4 : 1], b[(k & 4) != 0 ? 5 : 2]);
|
||||
m.transformPosition(p);
|
||||
grow(o, p.x, p.y, p.z);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
static float[] emptyBox() {
|
||||
return new float[] {
|
||||
Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE
|
||||
};
|
||||
}
|
||||
|
||||
static void union(float[] b, float[] o) {
|
||||
if (o[0] > o[3]) {
|
||||
return;
|
||||
}
|
||||
for (int k = 0; k < 3; k++) {
|
||||
b[k] = Math.min(b[k], o[k]);
|
||||
b[k + 3] = Math.max(b[k + 3], o[k + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
static void grow(float[] b, float x, float y, float z) {
|
||||
b[0] = Math.min(b[0], x);
|
||||
b[1] = Math.min(b[1], y);
|
||||
b[2] = Math.min(b[2], z);
|
||||
b[3] = Math.max(b[3], x);
|
||||
b[4] = Math.max(b[4], y);
|
||||
b[5] = Math.max(b[5], z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector2f;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Transform gizmo (translate / rotate / scale with axis and plane constraints). Pure math: the
|
||||
* viewport draws {@link #build} and feeds mouse input; drags produce {@link GizmoEvent}s with the
|
||||
* total delta since the drag started. The gizmo never changes the scene itself – listeners apply the
|
||||
* delta (e.g. as an undoable command) and call {@link #setTarget} to move the gizmo along.
|
||||
*/
|
||||
public final class Gizmo {
|
||||
public enum Mode {
|
||||
TRANSLATE,
|
||||
ROTATE,
|
||||
SCALE
|
||||
}
|
||||
|
||||
public enum Space {
|
||||
WORLD,
|
||||
LOCAL
|
||||
}
|
||||
|
||||
public enum Handle {
|
||||
NONE,
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
XY,
|
||||
YZ,
|
||||
XZ,
|
||||
CENTER;
|
||||
|
||||
/** Axis index 0..2 for X/Y/Z, the plane normal for XY/YZ/XZ, -1 otherwise. */
|
||||
int axis() {
|
||||
return switch (this) {
|
||||
case X, YZ -> 0;
|
||||
case Y, XZ -> 1;
|
||||
case Z, XY -> 2;
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
boolean plane() {
|
||||
return this == XY || this == YZ || this == XZ;
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapping steps; a step ≤ 0 disables that kind. Holding Ctrl while dragging inverts {@link #enabled}. */
|
||||
public record Snap(boolean enabled, float translate, float rotateDegrees, float scale) {
|
||||
public static final Snap DEFAULT = new Snap(false, 0.25f, 15f, 0.1f);
|
||||
}
|
||||
|
||||
private static final int[] AXIS_COLORS = {
|
||||
Legend.rgb(232, 64, 64), Legend.rgb(120, 205, 40), Legend.rgb(60, 120, 245)
|
||||
};
|
||||
private static final int ACTIVE = Legend.rgb(255, 222, 60);
|
||||
private static final float HIT_PX = 9;
|
||||
private static final Handle[] AXIS_HANDLES = {Handle.X, Handle.Y, Handle.Z};
|
||||
/** Translate plane handles by normal axis. */
|
||||
private static final Handle[] PLANE_HANDLES = {Handle.YZ, Handle.XZ, Handle.XY};
|
||||
|
||||
private Mode mode = Mode.TRANSLATE;
|
||||
private Space space = Space.WORLD;
|
||||
private boolean visible;
|
||||
private final Matrix4f target = new Matrix4f();
|
||||
private Snap snap = Snap.DEFAULT;
|
||||
/** Handle length in pixels. */
|
||||
public float sizePx = 95;
|
||||
|
||||
private Handle hover = Handle.NONE;
|
||||
private Handle active = Handle.NONE;
|
||||
private final Vector3f dragPivot = new Vector3f();
|
||||
private final Vector3f[] dragAxes = {new Vector3f(), new Vector3f(), new Vector3f()};
|
||||
private final Matrix4f dragStart = new Matrix4f();
|
||||
private final Vector3f dragHit = new Vector3f();
|
||||
private float dragT0;
|
||||
private float lastAngle;
|
||||
private float totalAngle;
|
||||
private final Vector2f dragMouse = new Vector2f();
|
||||
private GizmoEvent last;
|
||||
|
||||
public Mode mode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(Mode m) {
|
||||
mode = m;
|
||||
}
|
||||
|
||||
public Space space() {
|
||||
return space;
|
||||
}
|
||||
|
||||
public void setSpace(Space s) {
|
||||
space = s;
|
||||
}
|
||||
|
||||
public boolean visible() {
|
||||
return visible;
|
||||
}
|
||||
|
||||
public void setVisible(boolean v) {
|
||||
visible = v;
|
||||
if (!v) {
|
||||
hover = Handle.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
public Matrix4f target() {
|
||||
return new Matrix4f(target);
|
||||
}
|
||||
|
||||
/** Pivot (translation) and orientation (rotation part, used in local space) of the gizmo. */
|
||||
public void setTarget(Matrix4f m) {
|
||||
target.set(m);
|
||||
}
|
||||
|
||||
public Snap snap() {
|
||||
return snap;
|
||||
}
|
||||
|
||||
public void setSnap(Snap s) {
|
||||
snap = s;
|
||||
}
|
||||
|
||||
public boolean dragging() {
|
||||
return active != Handle.NONE;
|
||||
}
|
||||
|
||||
public Handle hover() {
|
||||
return hover;
|
||||
}
|
||||
|
||||
Vector3f pivot() {
|
||||
return target.getTranslation(new Vector3f());
|
||||
}
|
||||
|
||||
Vector3f[] axes() {
|
||||
Vector3f[] a = {new Vector3f(1, 0, 0), new Vector3f(0, 1, 0), new Vector3f(0, 0, 1)};
|
||||
if (space == Space.LOCAL || mode == Mode.SCALE) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
target.getColumn(i, a[i]);
|
||||
if (a[i].lengthSquared() < 1e-12f) {
|
||||
a[i].set(i == 0 ? 1 : 0, i == 1 ? 1 : 0, i == 2 ? 1 : 0);
|
||||
}
|
||||
a[i].normalize();
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private float length(Camera cam) {
|
||||
return sizePx * cam.pixelSize(pivot());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ hit testing
|
||||
|
||||
/** Handle under the pixel, updating the hover state. */
|
||||
public Handle hitTest(Camera cam, float mx, float my) {
|
||||
hover = visible && !dragging() ? pick(cam, mx, my) : hover;
|
||||
return hover;
|
||||
}
|
||||
|
||||
private Handle pick(Camera cam, float mx, float my) {
|
||||
Vector3f p = pivot();
|
||||
Vector3f[] ax = axes();
|
||||
float len = length(cam);
|
||||
Vector3f pp = cam.project(p);
|
||||
if (Float.isNaN(pp.x) || pp.z > 1 || pp.z < -1) {
|
||||
return Handle.NONE;
|
||||
}
|
||||
Vector2f m = new Vector2f(mx, my);
|
||||
if (mode != Mode.ROTATE && m.distance(pp.x, pp.y) < HIT_PX + 2) {
|
||||
return Handle.CENTER;
|
||||
}
|
||||
if (mode == Mode.TRANSLATE) {
|
||||
for (int n = 0; n < 3; n++) {
|
||||
if (inQuad(cam, planeQuad(p, ax, n, len), m)) {
|
||||
return PLANE_HANDLES[n];
|
||||
}
|
||||
}
|
||||
}
|
||||
Handle best = Handle.NONE;
|
||||
float bestD = HIT_PX;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
float d;
|
||||
if (mode == Mode.ROTATE) {
|
||||
d = ringDistance(cam, p, ax[i], len, m);
|
||||
} else {
|
||||
Vector3f a = cam.project(new Vector3f(ax[i]).mul(0.2f * len).add(p));
|
||||
Vector3f b = cam.project(new Vector3f(ax[i]).mul(len).add(p));
|
||||
d = segDist(m, a, b);
|
||||
}
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = AXIS_HANDLES[i];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Corners of the translate plane handle with normal axis n. */
|
||||
private static Vector3f[] planeQuad(Vector3f p, Vector3f[] ax, int n, float len) {
|
||||
Vector3f a = ax[(n + 1) % 3], b = ax[(n + 2) % 3];
|
||||
return new Vector3f[] {
|
||||
corner(p, a, b, 0.25f * len, 0.25f * len),
|
||||
corner(p, a, b, 0.5f * len, 0.25f * len),
|
||||
corner(p, a, b, 0.5f * len, 0.5f * len),
|
||||
corner(p, a, b, 0.25f * len, 0.5f * len)
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3f corner(Vector3f p, Vector3f a, Vector3f b, float sa, float sb) {
|
||||
return new Vector3f(a).mul(sa).add(new Vector3f(b).mul(sb)).add(p);
|
||||
}
|
||||
|
||||
/** Point at angle a on the circle of radius r about p spanned by u and v. */
|
||||
private static Vector3f circlePoint(Vector3f p, Vector3f u, Vector3f v, float r, double a) {
|
||||
return new Vector3f(u)
|
||||
.mul((float) Math.cos(a) * r)
|
||||
.add(new Vector3f(v).mul((float) Math.sin(a) * r))
|
||||
.add(p);
|
||||
}
|
||||
|
||||
private static boolean inQuad(Camera cam, Vector3f[] q, Vector2f m) {
|
||||
Vector3f[] s = new Vector3f[4];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
s[i] = cam.project(q[i]);
|
||||
if (Float.isNaN(s[i].x)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
int sign = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
Vector3f a = s[i], b = s[(i + 1) % 4];
|
||||
float c = (b.x - a.x) * (m.y - a.y) - (b.y - a.y) * (m.x - a.x);
|
||||
int sg = c > 0 ? 1 : -1;
|
||||
if (sign == 0) {
|
||||
sign = sg;
|
||||
} else if (sg != sign) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static float segDist(Vector2f m, Vector3f a, Vector3f b) {
|
||||
if (Float.isNaN(a.x) || Float.isNaN(b.x)) {
|
||||
return Float.MAX_VALUE;
|
||||
}
|
||||
float dx = b.x - a.x, dy = b.y - a.y;
|
||||
float l2 = dx * dx + dy * dy;
|
||||
float t = l2 < 1e-6f ? 0 : Math.max(0, Math.min(1, ((m.x - a.x) * dx + (m.y - a.y) * dy) / l2));
|
||||
return m.distance(a.x + t * dx, a.y + t * dy);
|
||||
}
|
||||
|
||||
private static float ringDistance(Camera cam, Vector3f p, Vector3f axis, float r, Vector2f m) {
|
||||
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
|
||||
float best = Float.MAX_VALUE;
|
||||
Vector3f prev = null;
|
||||
Vector3f toPivot =
|
||||
cam.ortho ? cam.forward() : new Vector3f(p).sub(cam.position).normalize();
|
||||
for (int k = 0; k <= 64; k++) {
|
||||
Vector3f w = circlePoint(p, u, v, r, k * Math.PI * 2 / 64);
|
||||
boolean front = new Vector3f(w).sub(p).dot(toPivot) <= 0.05f * r;
|
||||
Vector3f s = cam.project(w);
|
||||
if (prev != null && front) {
|
||||
best = Math.min(best, segDist(m, prev, s));
|
||||
}
|
||||
prev = s;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
static Vector3f perp(Vector3f a) {
|
||||
Vector3f t = Math.abs(a.y) < 0.9f ? new Vector3f(0, 1, 0) : new Vector3f(1, 0, 0);
|
||||
return t.cross(a).normalize();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dragging
|
||||
|
||||
/** Starts dragging the hovered handle; returns the START event or {@code null}. */
|
||||
public GizmoEvent begin(Camera cam, float mx, float my) {
|
||||
Handle h = hitTest(cam, mx, my);
|
||||
if (h == Handle.NONE) {
|
||||
return null;
|
||||
}
|
||||
active = h;
|
||||
dragPivot.set(pivot());
|
||||
Vector3f[] ax = axes();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
dragAxes[i].set(ax[i]);
|
||||
}
|
||||
dragStart.set(target);
|
||||
dragMouse.set(mx, my);
|
||||
totalAngle = 0;
|
||||
Vector3f[] ray = cam.ray(mx, my);
|
||||
if (mode == Mode.ROTATE) {
|
||||
lastAngle = planeAngle(cam, ray, mx, my);
|
||||
} else if (h.axis() >= 0 && !h.plane()) {
|
||||
dragT0 = axisParam(ray, dragAxes[h.axis()]);
|
||||
} else {
|
||||
Vector3f hit = planeHit(cam, ray, h);
|
||||
dragHit.set(hit != null ? hit : dragPivot);
|
||||
}
|
||||
last = event(GizmoEvent.Phase.START, new Vector3f(), 0, new Vector3f(1, 1, 1));
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Continues the drag; invertSnap = Ctrl held. */
|
||||
public GizmoEvent drag(Camera cam, float mx, float my, boolean invertSnap) {
|
||||
if (!dragging()) {
|
||||
return null;
|
||||
}
|
||||
boolean sn = snap.enabled() != invertSnap;
|
||||
Vector3f[] ray = cam.ray(mx, my);
|
||||
Vector3f tr = new Vector3f();
|
||||
float angle = 0;
|
||||
Vector3f sc = new Vector3f(1, 1, 1);
|
||||
int ai = active.axis();
|
||||
switch (mode) {
|
||||
case TRANSLATE -> {
|
||||
if (ai >= 0 && !active.plane()) {
|
||||
float t = axisParam(ray, dragAxes[ai]) - dragT0;
|
||||
if (sn && snap.translate() > 0) {
|
||||
t = Math.round(t / snap.translate()) * snap.translate();
|
||||
}
|
||||
tr.set(dragAxes[ai]).mul(t);
|
||||
} else {
|
||||
Vector3f hit = planeHit(cam, ray, active);
|
||||
if (hit != null) {
|
||||
tr.set(hit).sub(dragHit);
|
||||
if (sn && snap.translate() > 0) {
|
||||
float s = snap.translate();
|
||||
tr.set(Math.round(tr.x / s) * s, Math.round(tr.y / s) * s, Math.round(tr.z / s) * s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case ROTATE -> {
|
||||
float a = planeAngle(cam, ray, mx, my);
|
||||
float d = a - lastAngle;
|
||||
if (d > Math.PI) {
|
||||
d -= (float) (2 * Math.PI);
|
||||
} else if (d < -Math.PI) {
|
||||
d += (float) (2 * Math.PI);
|
||||
}
|
||||
totalAngle += d;
|
||||
lastAngle = a;
|
||||
angle = totalAngle;
|
||||
if (sn && snap.rotateDegrees() > 0) {
|
||||
float st = (float) Math.toRadians(snap.rotateDegrees());
|
||||
angle = Math.round(angle / st) * st;
|
||||
}
|
||||
}
|
||||
case SCALE -> {
|
||||
float f;
|
||||
if (active == Handle.CENTER) {
|
||||
Vector3f pp = cam.project(dragPivot);
|
||||
float d0 = Math.max(4, dragMouse.distance(pp.x, pp.y));
|
||||
f = new Vector2f(mx, my).distance(pp.x, pp.y) / d0;
|
||||
} else {
|
||||
float t = axisParam(ray, dragAxes[ai]);
|
||||
f = Math.abs(dragT0) < 1e-5f ? 1 : t / dragT0;
|
||||
}
|
||||
if (sn && snap.scale() > 0) {
|
||||
f = 1 + Math.round((f - 1) / snap.scale()) * snap.scale();
|
||||
}
|
||||
f = Math.max(0.01f, f);
|
||||
if (active == Handle.CENTER) {
|
||||
sc.set(f);
|
||||
} else {
|
||||
sc.setComponent(ai, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
last = event(GizmoEvent.Phase.DRAG, tr, angle, sc);
|
||||
return last;
|
||||
}
|
||||
|
||||
public GizmoEvent end() {
|
||||
if (!dragging()) {
|
||||
return null;
|
||||
}
|
||||
GizmoEvent e = last.withPhase(GizmoEvent.Phase.END);
|
||||
active = Handle.NONE;
|
||||
return e;
|
||||
}
|
||||
|
||||
public GizmoEvent cancel() {
|
||||
if (!dragging()) {
|
||||
return null;
|
||||
}
|
||||
GizmoEvent e = event(GizmoEvent.Phase.CANCEL, new Vector3f(), 0, new Vector3f(1, 1, 1));
|
||||
active = Handle.NONE;
|
||||
return e;
|
||||
}
|
||||
|
||||
private GizmoEvent event(GizmoEvent.Phase ph, Vector3f tr, float angle, Vector3f sc) {
|
||||
Vector3f axis = active.axis() >= 0 ? new Vector3f(dragAxes[active.axis()]) : new Vector3f(0, 1, 0);
|
||||
return new GizmoEvent(
|
||||
ph, mode, active, new Vector3f(dragPivot), tr, axis, angle, sc, new Matrix4f(dragStart), space);
|
||||
}
|
||||
|
||||
/** Parameter t of the point on the axis line through the pivot closest to the ray. */
|
||||
private float axisParam(Vector3f[] ray, Vector3f a) {
|
||||
Vector3f o = ray[0], d = ray[1];
|
||||
Vector3f w = new Vector3f(dragPivot).sub(o);
|
||||
float b = a.dot(d);
|
||||
float den = 1 - b * b;
|
||||
if (Math.abs(den) < 1e-5f) {
|
||||
return dragT0;
|
||||
}
|
||||
return (b * d.dot(w) - a.dot(w)) / den;
|
||||
}
|
||||
|
||||
private Vector3f planeHit(Camera cam, Vector3f[] ray, Handle h) {
|
||||
Vector3f n = h.plane() ? new Vector3f(dragAxes[h.axis()]) : cam.forward();
|
||||
if (mode == Mode.TRANSLATE && h.plane() && Math.abs(n.dot(ray[1])) < 0.02f) {
|
||||
return null;
|
||||
}
|
||||
float den = n.dot(ray[1]);
|
||||
if (Math.abs(den) < 1e-6f) {
|
||||
return null;
|
||||
}
|
||||
float t = n.dot(new Vector3f(dragPivot).sub(ray[0])) / den;
|
||||
return new Vector3f(ray[1]).mul(t).add(ray[0]);
|
||||
}
|
||||
|
||||
/** Angle of the mouse around the rotation axis (plane hit, or screen angle when edge-on). */
|
||||
private float planeAngle(Camera cam, Vector3f[] ray, float mx, float my) {
|
||||
Vector3f axis = dragAxes[active.axis()];
|
||||
float den = axis.dot(ray[1]);
|
||||
Vector3f pp = cam.project(dragPivot);
|
||||
float screen = (float) Math.atan2(my - pp.y, mx - pp.x);
|
||||
if (Math.abs(den) < 0.2f) {
|
||||
// edge-on: screen-space angle, sign from which way the axis points at the viewer
|
||||
// (the left-handed view mirrors screen angles)
|
||||
return screen * Math.signum(axis.dot(cam.forward()) == 0 ? 1 : -axis.dot(cam.forward()));
|
||||
}
|
||||
float t = axis.dot(new Vector3f(dragPivot).sub(ray[0])) / den;
|
||||
Vector3f hit = new Vector3f(ray[1]).mul(t).add(ray[0]).sub(dragPivot);
|
||||
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
|
||||
return (float) Math.atan2(hit.dot(v), hit.dot(u));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ geometry
|
||||
|
||||
/** Handle geometry in world space for the current camera. */
|
||||
void build(Camera cam, Overlay lines, Overlay tris) {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
// the listener moves the target along while dragging; the axes stay as at drag start
|
||||
Vector3f p = pivot();
|
||||
Vector3f[] ax = dragging() ? dragAxes : axes();
|
||||
float px = cam.pixelSize(p);
|
||||
float len = sizePx * px;
|
||||
Handle cur = dragging() ? active : hover;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int col = cur == AXIS_HANDLES[i] ? ACTIVE : AXIS_COLORS[i];
|
||||
if (dragging()
|
||||
&& active != AXIS_HANDLES[i]
|
||||
&& !(active.plane() && active.axis() != i)
|
||||
&& active != Handle.CENTER) {
|
||||
col = Legend.withAlpha(col, 70);
|
||||
}
|
||||
Vector3f a = ax[i];
|
||||
if (mode == Mode.ROTATE) {
|
||||
ring(cam, tris, p, a, len, 2.2f * px, col);
|
||||
} else {
|
||||
Vector3f s0 = new Vector3f(a).mul(0.2f * len).add(p);
|
||||
Vector3f s1 = new Vector3f(a)
|
||||
.mul(mode == Mode.SCALE ? 0.92f * len : 0.82f * len)
|
||||
.add(p);
|
||||
ribbon(cam, tris, s0, s1, 1.6f * px, col);
|
||||
if (mode == Mode.TRANSLATE) {
|
||||
cone(tris, s1, new Vector3f(a).mul(len).add(p), a, 0.065f * len, col);
|
||||
} else {
|
||||
cube(tris, new Vector3f(a).mul(len).add(p), 0.055f * len, ax, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode == Mode.TRANSLATE) {
|
||||
for (int n = 0; n < 3; n++) {
|
||||
int col = cur == PLANE_HANDLES[n] ? ACTIVE : AXIS_COLORS[n];
|
||||
Vector3f[] q = planeQuad(p, ax, n, len);
|
||||
int fill = Legend.withAlpha(col, cur == PLANE_HANDLES[n] ? 150 : 80);
|
||||
tri(tris, q[0], q[1], q[2], fill);
|
||||
tri(tris, q[0], q[2], q[3], fill);
|
||||
for (int k = 0; k < 4; k++) {
|
||||
line(lines, q[k], q[(k + 1) % 4], col);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode != Mode.ROTATE) {
|
||||
int col = cur == Handle.CENTER ? ACTIVE : Legend.rgb(235, 235, 235);
|
||||
Vector3f r = cam.right().mul(5 * px), u = cam.up().mul(5 * px);
|
||||
Vector3f c0 = new Vector3f(p).sub(r).sub(u),
|
||||
c1 = new Vector3f(p).add(r).sub(u);
|
||||
Vector3f c2 = new Vector3f(p).add(r).add(u),
|
||||
c3 = new Vector3f(p).sub(r).add(u);
|
||||
tri(tris, c0, c1, c2, col);
|
||||
tri(tris, c0, c2, c3, col);
|
||||
}
|
||||
if (dragging() && mode == Mode.TRANSLATE && active.axis() >= 0 && !active.plane()) {
|
||||
Vector3f a = dragAxes[active.axis()];
|
||||
line(
|
||||
lines,
|
||||
new Vector3f(a).mul(-1000).add(p),
|
||||
new Vector3f(a).mul(1000).add(p),
|
||||
Legend.withAlpha(AXIS_COLORS[active.axis()], 160));
|
||||
}
|
||||
}
|
||||
|
||||
private static void line(Overlay o, Vector3f a, Vector3f b, int col) {
|
||||
o.line(a.x, a.y, a.z, b.x, b.y, b.z, col);
|
||||
}
|
||||
|
||||
private static void tri(Overlay o, Vector3f a, Vector3f b, Vector3f c, int col) {
|
||||
o.triangle(new double[] {a.x, a.y, a.z}, new double[] {b.x, b.y, b.z}, new double[] {c.x, c.y, c.z}, col);
|
||||
}
|
||||
|
||||
/** Camera-facing quad strip for a thick segment. */
|
||||
private static void ribbon(Camera cam, Overlay o, Vector3f a, Vector3f b, float halfW, int col) {
|
||||
Vector3f dir = new Vector3f(b).sub(a);
|
||||
Vector3f view = cam.ortho ? cam.forward() : new Vector3f(a).sub(cam.position);
|
||||
Vector3f side = dir.cross(view, new Vector3f());
|
||||
if (side.lengthSquared() < 1e-12f) {
|
||||
side = perp(dir.normalize(new Vector3f()));
|
||||
}
|
||||
side.normalize().mul(halfW);
|
||||
Vector3f a0 = new Vector3f(a).sub(side), a1 = new Vector3f(a).add(side);
|
||||
Vector3f b0 = new Vector3f(b).sub(side), b1 = new Vector3f(b).add(side);
|
||||
tri(o, a0, b0, b1, col);
|
||||
tri(o, a0, b1, a1, col);
|
||||
}
|
||||
|
||||
private static void ring(Camera cam, Overlay o, Vector3f p, Vector3f axis, float r, float halfW, int col) {
|
||||
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
|
||||
Vector3f prev = null;
|
||||
Vector3f toPivot =
|
||||
cam.ortho ? cam.forward() : new Vector3f(p).sub(cam.position).normalize();
|
||||
for (int k = 0; k <= 72; k++) {
|
||||
Vector3f w = circlePoint(p, u, v, r, k * Math.PI * 2 / 72);
|
||||
if (prev != null) {
|
||||
Vector3f mid = new Vector3f(prev).add(w).mul(0.5f);
|
||||
boolean front = new Vector3f(mid).sub(p).dot(toPivot) <= 0.05f * r;
|
||||
ribbon(cam, o, prev, w, halfW, front ? col : Legend.withAlpha(col, 50));
|
||||
}
|
||||
prev = w;
|
||||
}
|
||||
}
|
||||
|
||||
private static void cone(Overlay o, Vector3f base, Vector3f tip, Vector3f axis, float r, int col) {
|
||||
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
|
||||
int n = 14;
|
||||
int shade = Legend.dim(col, 0.7);
|
||||
for (int k = 0; k < n; k++) {
|
||||
Vector3f q0 = circlePoint(base, u, v, r, k * Math.PI * 2 / n);
|
||||
Vector3f q1 = circlePoint(base, u, v, r, (k + 1) * Math.PI * 2 / n);
|
||||
tri(o, q0, q1, tip, k % 2 == 0 ? col : Legend.dim(col, 0.88));
|
||||
tri(o, q1, q0, base, shade);
|
||||
}
|
||||
}
|
||||
|
||||
private static void cube(Overlay o, Vector3f c, float s, Vector3f[] ax, int col) {
|
||||
Vector3f[] p = new Vector3f[8];
|
||||
for (int k = 0; k < 8; k++) {
|
||||
p[k] = new Vector3f(c)
|
||||
.add(new Vector3f(ax[0]).mul((k & 1) != 0 ? s : -s))
|
||||
.add(new Vector3f(ax[1]).mul((k & 2) != 0 ? s : -s))
|
||||
.add(new Vector3f(ax[2]).mul((k & 4) != 0 ? s : -s));
|
||||
}
|
||||
int[][] f = {{0, 1, 3, 2}, {4, 6, 7, 5}, {0, 4, 5, 1}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 5, 7, 3}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
int cc = Legend.dim(col, 0.75 + 0.05 * i);
|
||||
tri(o, p[f[i][0]], p[f[i][1]], p[f[i][2]], cc);
|
||||
tri(o, p[f[i][0]], p[f[i][2]], p[f[i][3]], cc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Gizmo drag state. All deltas are totals since {@link Phase#START} (relative to {@link #start}), so
|
||||
* a listener can show a live preview and emit a single undoable command at {@link Phase#END}; on
|
||||
* {@link Phase#CANCEL} (Escape) it restores {@link #start}.
|
||||
*
|
||||
* @param pivot gizmo position at drag start (world)
|
||||
* @param translation world-space offset (TRANSLATE)
|
||||
* @param axis world-space rotation axis (ROTATE) or the dragged axis
|
||||
* @param angle radians about axis, right-handed (ROTATE)
|
||||
* @param scale factors along the target's local axes (SCALE)
|
||||
* @param start target transform at drag start
|
||||
*/
|
||||
public record GizmoEvent(
|
||||
Phase phase,
|
||||
Gizmo.Mode mode,
|
||||
Gizmo.Handle handle,
|
||||
Vector3f pivot,
|
||||
Vector3f translation,
|
||||
Vector3f axis,
|
||||
float angle,
|
||||
Vector3f scale,
|
||||
Matrix4f start,
|
||||
Gizmo.Space space) {
|
||||
public enum Phase {
|
||||
START,
|
||||
DRAG,
|
||||
END,
|
||||
CANCEL
|
||||
}
|
||||
|
||||
GizmoEvent withPhase(Phase p) {
|
||||
return new GizmoEvent(p, mode, handle, pivot, translation, axis, angle, scale, start, space);
|
||||
}
|
||||
|
||||
/** The delta applied to a transform m (any object moved together with the gizmo target). */
|
||||
public Matrix4f applyTo(Matrix4f m) {
|
||||
Matrix4f r = new Matrix4f();
|
||||
switch (mode) {
|
||||
case TRANSLATE -> r.translation(translation).mul(m);
|
||||
case ROTATE -> r.translation(pivot)
|
||||
.rotate(angle, axis)
|
||||
.translate(-pivot.x, -pivot.y, -pivot.z)
|
||||
.mul(m);
|
||||
case SCALE -> r.set(m).scale(scale);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** {@link #applyTo} for the gizmo target itself. */
|
||||
public Matrix4f result() {
|
||||
return applyTo(start);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,180 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Overlay colours (0xRRGGBBAA), same legend as the in-game debug wireframe (notes/debugviz.md). */
|
||||
public final class Legend {
|
||||
private Legend() {}
|
||||
|
||||
public static final int FLOOR = rgb(40, 255, 40);
|
||||
public static final int SLOPE = rgb(200, 255, 0);
|
||||
public static final int WALL = rgb(0, 150, 255);
|
||||
public static final int CEILING = rgb(255, 130, 0);
|
||||
public static final int MAT_OTHER = rgb(230, 230, 230);
|
||||
|
||||
/** Collision materials with a fixed colour (16 exit floor, 17/18 selective, 30 no floor). */
|
||||
public static final Map<Integer, Integer> MATERIALS = Map.ofEntries(
|
||||
Map.entry(30, rgb(255, 0, 255)),
|
||||
Map.entry(16, rgb(190, 60, 255)),
|
||||
Map.entry(17, rgb(255, 255, 0)),
|
||||
Map.entry(18, rgb(255, 200, 120)),
|
||||
Map.entry(1, rgb(0, 200, 200)),
|
||||
Map.entry(2, rgb(255, 255, 255)),
|
||||
Map.entry(3, rgb(255, 120, 170)),
|
||||
Map.entry(5, rgb(120, 255, 200)),
|
||||
Map.entry(6, rgb(160, 120, 255)),
|
||||
Map.entry(7, rgb(255, 170, 80)),
|
||||
Map.entry(8, rgb(0, 130, 60)),
|
||||
Map.entry(9, rgb(150, 200, 255)),
|
||||
Map.entry(10, rgb(150, 150, 0)),
|
||||
Map.entry(11, rgb(150, 90, 40)),
|
||||
Map.entry(13, rgb(255, 130, 110)),
|
||||
Map.entry(15, rgb(255, 255, 150)),
|
||||
Map.entry(20, rgb(0, 120, 140)),
|
||||
Map.entry(26, rgb(110, 40, 160)),
|
||||
Map.entry(27, rgb(190, 190, 190)),
|
||||
Map.entry(28, rgb(255, 0, 140)),
|
||||
Map.entry(33, rgb(150, 0, 0)),
|
||||
Map.entry(255, rgb(20, 20, 20)));
|
||||
|
||||
/** Edge colour of a collision polygon from its material and the y component of its normal. */
|
||||
public static int collision(int material, double ny) {
|
||||
if (material != 0) {
|
||||
return MATERIALS.getOrDefault(material, MAT_OTHER);
|
||||
}
|
||||
if (ny >= 0.7) {
|
||||
return FLOOR;
|
||||
}
|
||||
if (ny >= 0.2) {
|
||||
return SLOPE;
|
||||
}
|
||||
if (ny > -0.2) {
|
||||
return WALL;
|
||||
}
|
||||
return CEILING;
|
||||
}
|
||||
|
||||
public enum Category {
|
||||
START(rgb(255, 255, 255)),
|
||||
EXIT(rgb(190, 60, 255)),
|
||||
CHECKPOINT(rgb(0, 255, 140)),
|
||||
CAMERA(rgb(0, 255, 255)),
|
||||
TRIGGER(rgb(255, 255, 0)),
|
||||
FORCE(rgb(255, 90, 0)),
|
||||
SECRET(rgb(255, 200, 0)),
|
||||
MISC(rgb(170, 170, 170)),
|
||||
ENVIRONMENT(rgb(255, 100, 180)),
|
||||
OBJECT(rgb(90, 90, 90));
|
||||
|
||||
public final int color;
|
||||
|
||||
Category(int color) {
|
||||
this.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Set<String> START_T =
|
||||
Set.of("Llama", "KuscoAndPachaTogetherOnATree", "RollerCoaster", "LlamaChase");
|
||||
private static final Set<String> EXIT_T = Set.of("LevelDoor", "VillageEndDoor", "EndGameDoor", "TeleportDoor");
|
||||
private static final Set<String> FORCE_T = Set.of(
|
||||
"FallOffNoHurt",
|
||||
"ObjectDontDropInBox",
|
||||
"RiverCurrentBox",
|
||||
"RiverRebound",
|
||||
"SuckingWPs",
|
||||
"ObjectSuckPoints",
|
||||
"RiverTrackChange",
|
||||
"TrackSwitch",
|
||||
"BackgroundRotationChanger",
|
||||
"RiverWhirlpool");
|
||||
private static final Set<String> MISC_T = Set.of(
|
||||
"CheatTeleport",
|
||||
"Wompy",
|
||||
"WompySimple",
|
||||
"WompyRollerCoaster",
|
||||
"Rain",
|
||||
"DrippingWater",
|
||||
"WaterfallMist",
|
||||
"Smoker",
|
||||
"CoinLine",
|
||||
"CoinCircle",
|
||||
"CoinRollerCoaster",
|
||||
"CoinBat",
|
||||
"CoinSpawnOracle",
|
||||
"MusicBox",
|
||||
"SpitPlatformTarget",
|
||||
"NewBlockheadKidHelper",
|
||||
"FlockOfBirds",
|
||||
"Bats",
|
||||
"RiverBats",
|
||||
"Scorpions1",
|
||||
"Scorpions3",
|
||||
"Scorpions5",
|
||||
"GhostCatacombs",
|
||||
"LabDumbGuards",
|
||||
"FlyStuck",
|
||||
"AppearingPlatforms",
|
||||
"AppearingPlatformsCity",
|
||||
"AppearingPlatformsJungle",
|
||||
"AppearingPlatformsLab",
|
||||
"AppearingPlatformsVillage",
|
||||
"fat_bird",
|
||||
"Column2",
|
||||
"TipoHelp");
|
||||
private static final Set<String> TRIG_T = Set.of("ChangeToBunny", "ChangeToTurtle", "RiverStartSign");
|
||||
|
||||
/** Marker category of a strat type (invisible logic objects), {@code null} for ordinary objects. */
|
||||
public static Category category(String name) {
|
||||
String n = name.replace("(spawn)", "");
|
||||
if (START_T.contains(n)) {
|
||||
return Category.START;
|
||||
}
|
||||
if (EXIT_T.contains(n)) {
|
||||
return Category.EXIT;
|
||||
}
|
||||
if (n.startsWith("CheckPoint")) {
|
||||
return Category.CHECKPOINT;
|
||||
}
|
||||
if (n.contains("Camera")) {
|
||||
return Category.CAMERA;
|
||||
}
|
||||
if (n.equals("Secret")) {
|
||||
return Category.SECRET;
|
||||
}
|
||||
if (FORCE_T.contains(n) || n.startsWith("Force") || n.startsWith("WaterRebound")) {
|
||||
return Category.FORCE;
|
||||
}
|
||||
if (n.contains("Trigger") || n.startsWith("Say") || TRIG_T.contains(n)) {
|
||||
return Category.TRIGGER;
|
||||
}
|
||||
if (MISC_T.contains(n) || n.endsWith("Spawner") || n.contains("Helper")) {
|
||||
return Category.MISC;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int rgb(int r, int g, int b) {
|
||||
return rgba(r, g, b, 0xff);
|
||||
}
|
||||
|
||||
public static int rgba(int r, int g, int b, int a) {
|
||||
return r << 24 | g << 16 | b << 8 | (a & 0xff);
|
||||
}
|
||||
|
||||
/** Colour 0xRRGGBB of a visibility region (golden-ratio hues). */
|
||||
public static int regionRgb(int region) {
|
||||
return java.awt.Color.HSBtoRGB((region * 0.618034f) % 1f, 0.65f, 0.95f) & 0xffffff;
|
||||
}
|
||||
|
||||
public static int withAlpha(int rgba, int a) {
|
||||
return (rgba & 0xffffff00) | (a & 0xff);
|
||||
}
|
||||
|
||||
public static int dim(int rgba, double f) {
|
||||
int r = (int) (((rgba >>> 24) & 0xff) * f);
|
||||
int g = (int) (((rgba >>> 16) & 0xff) * f);
|
||||
int b = (int) (((rgba >>> 8) & 0xff) * f);
|
||||
return r << 24 | g << 16 | b << 8 | (rgba & 0xff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Collision;
|
||||
import com.eliasmueller.llamamod.format.Face;
|
||||
import com.eliasmueller.llamamod.format.Geometry;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Model;
|
||||
import com.eliasmueller.llamamod.format.PyMath;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Trak;
|
||||
import com.eliasmueller.llamamod.format.Vertex;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Builds a {@link RenderScene} straight from a level WAD (format API). Pick ids: {@link PickId#TILE}
|
||||
* + tile index, {@link PickId#OBJECT} + strat index, {@link PickId#WAYPOINT} + path node index,
|
||||
* {@link PickId#ZONE} + environment zone index.
|
||||
*/
|
||||
public final class LevelSceneAdapter {
|
||||
public static final String LAYER_TILES = "tiles";
|
||||
public static final String LAYER_OBJECTS = "objects";
|
||||
public static final String LAYER_MARKERS = "markers";
|
||||
public static final String LAYER_WAYPOINTS = "waypoints";
|
||||
public static final String LAYER_ZONES = "zones";
|
||||
public static final String LAYER_COLLISION = "collision";
|
||||
|
||||
/** Converted level plus lookup data for labels. */
|
||||
public record Result(
|
||||
RenderScene scene,
|
||||
Level level,
|
||||
List<String> stratNames,
|
||||
List<RenderMesh> tileMeshes,
|
||||
int gridW,
|
||||
int gridH) {}
|
||||
|
||||
private LevelSceneAdapter() {}
|
||||
|
||||
public static Result fromWad(byte[] wadBytes) {
|
||||
Level lv = Level.parse(wadBytes);
|
||||
RenderScene sc = new RenderScene();
|
||||
TextChunk text = TextChunk.parse(lv.wad().require("TEXT"));
|
||||
List<TextChunk.TextureEntry> tex = text.textureEntries();
|
||||
int[] pageClass = new int[text.pages.size()];
|
||||
for (int i = 0; i < text.pages.size(); i++) {
|
||||
TextChunk.Page p = text.pages.get(i);
|
||||
pageClass[i] = p.flags & 0x7f;
|
||||
sc.pages.add(page(p));
|
||||
}
|
||||
MapChunk map = lv.map();
|
||||
List<RenderMesh> tileMeshes = new ArrayList<>();
|
||||
for (int i = 0; i < lv.models().size(); i++) {
|
||||
Model m = lv.models().get(i);
|
||||
RenderMesh rm = mesh("trak" + i, m, tex, text, pageClass, 0);
|
||||
rm.overlay = collision(m);
|
||||
tileMeshes.add(rm);
|
||||
}
|
||||
addTiles(sc, lv, tileMeshes);
|
||||
Overlay wp = new Overlay();
|
||||
List<String> names =
|
||||
addStrats(sc, lv, wp, (name, model) -> mesh(name, model, tex, text, pageClass, RenderMesh.LIT));
|
||||
addWaypoints(sc, map, wp);
|
||||
sc.overlays.put(LAYER_WAYPOINTS, wp);
|
||||
addZones(sc, map);
|
||||
return new Result(sc, lv, names, tileMeshes, map.width, map.height);
|
||||
}
|
||||
|
||||
private static void addTiles(RenderScene sc, Level lv, List<RenderMesh> tileMeshes) {
|
||||
MapChunk map = lv.map();
|
||||
List<MapChunk.TileColors> cols = map.tileColors(Trak.vertCounts(lv.models()));
|
||||
for (int i = 0; i < map.tiles.size(); i++) {
|
||||
MapChunk.Tile t = map.tiles.get(i);
|
||||
RenderInstance ri =
|
||||
new RenderInstance(PickId.of(PickId.TILE, i), RenderInstance.Kind.TILE, tileMeshes.get(t.model()));
|
||||
ri.transform
|
||||
.translation((float) t.x(), (float) t.y(), (float) t.z())
|
||||
.rotateY((float) -angle(t.rotUnsigned()));
|
||||
int[] c = cols.get(i).frames().get(0);
|
||||
int[] vc = new int[c.length];
|
||||
for (int k = 0; k < c.length; k++) {
|
||||
// u32 little endian R, G, B, frame count
|
||||
vc[k] = (c[k] & 0xff) << 24 | ((c[k] >>> 8) & 0xff) << 16 | ((c[k] >>> 16) & 0xff) << 8 | 0xff;
|
||||
}
|
||||
ri.vertexColors = vc;
|
||||
ri.layer = LAYER_TILES;
|
||||
sc.instances.add(ri);
|
||||
}
|
||||
}
|
||||
|
||||
/** Object instances and markers of all strats (+ strat → first waypoint lines); returns the names. */
|
||||
private static List<String> addStrats(
|
||||
RenderScene sc, Level lv, Overlay wp, BiFunction<String, Model, RenderMesh> meshes) {
|
||||
MapChunk map = lv.map();
|
||||
byte[] stpc = lv.wad().require("STPC");
|
||||
StratModels sm = new StratModels(stpc, lv.wfpc());
|
||||
Map<Long, RenderMesh> objMeshes = new HashMap<>();
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 0; i < map.strats.size(); i++) {
|
||||
MapChunk.Strat s = map.strats.get(i);
|
||||
String name = Strats.nameOr(stpc, s.code(), sm.codeStart(), "?@0x" + Integer.toHexString(s.code()));
|
||||
names.add(name);
|
||||
int id = PickId.of(PickId.OBJECT, i);
|
||||
float x = (float) MapChunk.fx(s.x()), y = (float) MapChunk.fx(s.y()), z = (float) -MapChunk.fx(s.z());
|
||||
float yaw = (float) angle(s.ry() & 0xfff);
|
||||
Legend.Category cat = Legend.category(name);
|
||||
boolean withModel = cat == null || cat == Legend.Category.CHECKPOINT || cat == Legend.Category.START;
|
||||
int mi = withModel ? sm.modelOf(s.code()) : -1;
|
||||
Model model = mi >= 0 ? sm.models.get(mi) : null;
|
||||
long key = mi;
|
||||
boolean hasModel = model != null;
|
||||
if (model != null && StratModels.skinned(model)) {
|
||||
model = sm.posed(s.code(), mi);
|
||||
key = (long) s.code() << 32 | mi;
|
||||
}
|
||||
boolean drawn = model != null && !model.faces.isEmpty();
|
||||
if (drawn) {
|
||||
Model mdl = model;
|
||||
RenderMesh rm = objMeshes.computeIfAbsent(key, k -> meshes.apply("stpc" + mi, mdl));
|
||||
RenderInstance ri = new RenderInstance(id, RenderInstance.Kind.OBJECT, rm);
|
||||
ri.transform.translation(x, y, z).rotateY(-yaw);
|
||||
ri.layer = LAYER_OBJECTS;
|
||||
sc.instances.add(ri);
|
||||
}
|
||||
if (!drawn || cat != null) {
|
||||
sc.markers.add(stratMarker(sc, id, name, cat, hasModel, x, y, z, yaw));
|
||||
}
|
||||
if (s.waypoint() >= 0 && s.waypoint() < map.nodes.size()) {
|
||||
MapChunk.PathNode nd = map.nodes.get(s.waypoint());
|
||||
int c = Legend.withAlpha(Legend.dim((cat != null ? cat : Legend.Category.MISC).color, 0.8), 110);
|
||||
wp.line(x, y + 0.02f, z, nodeX(nd), nodeY(nd), nodeZ(nd), c);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Marker of an invisible strat (category) or of an object whose model cannot be drawn. */
|
||||
private static Marker stratMarker(
|
||||
RenderScene sc,
|
||||
int id,
|
||||
String name,
|
||||
Legend.Category cat,
|
||||
boolean hasModel,
|
||||
float x,
|
||||
float y,
|
||||
float z,
|
||||
float yaw) {
|
||||
Marker mk = new Marker(id, x, y, z, (cat != null ? cat : Legend.Category.OBJECT).color);
|
||||
mk.label = name;
|
||||
if (cat == null) {
|
||||
mk.size = 0.05f;
|
||||
mk.pole = 0.06f;
|
||||
if (hasModel) {
|
||||
// model that could not be drawn (unposed character)
|
||||
mk.shape = Marker.Shape.DIAMOND;
|
||||
mk.size = 0.12f;
|
||||
mk.pole = 0.3f;
|
||||
}
|
||||
} else if (cat == Legend.Category.START) {
|
||||
mk.shape = Marker.Shape.ARROW;
|
||||
mk.size = 0.3f;
|
||||
mk.yaw = yaw;
|
||||
} else {
|
||||
mk.size = 0.06f;
|
||||
mk.pole = 0.35f;
|
||||
if (cat == Legend.Category.CAMERA || cat == Legend.Category.CHECKPOINT) {
|
||||
mk.yaw = yaw;
|
||||
}
|
||||
}
|
||||
if (cat == Legend.Category.EXIT) {
|
||||
double cx = Math.floor(x), cz = Math.floor(z);
|
||||
sc.overlays
|
||||
.computeIfAbsent(LAYER_ZONES, k -> new Overlay())
|
||||
.box(cx, y - 0.05, cz, cx + 1, y + 1, cz + 1, cat.color);
|
||||
}
|
||||
return mk;
|
||||
}
|
||||
|
||||
private static void addWaypoints(RenderScene sc, MapChunk map, Overlay wp) {
|
||||
for (int i = 0; i < map.nodes.size(); i++) {
|
||||
MapChunk.PathNode nd = map.nodes.get(i);
|
||||
float x = nodeX(nd), y = nodeY(nd), z = nodeZ(nd);
|
||||
Marker mk = new Marker(PickId.of(PickId.WAYPOINT, i), x, y, z, Legend.rgb(255, 230, 120));
|
||||
mk.size = 0.035f;
|
||||
mk.shape = Marker.Shape.DIAMOND;
|
||||
mk.layer = LAYER_WAYPOINTS;
|
||||
mk.label = "waypoint " + i;
|
||||
sc.markers.add(mk);
|
||||
int nx = nd.next();
|
||||
if (nx != 0 && nx < map.nodes.size()) {
|
||||
MapChunk.PathNode b = map.nodes.get(nx);
|
||||
wp.line(x, y, z, nodeX(b), nodeY(b), nodeZ(b), Legend.withAlpha(Legend.rgb(255, 210, 80), 150));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Export-space position of a path node (game z negated). */
|
||||
private static float nodeX(MapChunk.PathNode n) {
|
||||
return (float) MapChunk.fx(n.x());
|
||||
}
|
||||
|
||||
private static float nodeY(MapChunk.PathNode n) {
|
||||
return (float) MapChunk.fx(n.y());
|
||||
}
|
||||
|
||||
private static float nodeZ(MapChunk.PathNode n) {
|
||||
return (float) -MapChunk.fx(n.z());
|
||||
}
|
||||
|
||||
/** Backdrop colour of the default zone, boxes + markers of the other environment zones. */
|
||||
private static void addZones(RenderScene sc, MapChunk map) {
|
||||
Overlay zones = sc.overlays.computeIfAbsent(LAYER_ZONES, k -> new Overlay());
|
||||
for (MapChunk.EnvZone z : map.envZones) {
|
||||
if (z.isDefault()) {
|
||||
// backdrop/fog colour (file offset 0x4e, 0x00BBGGRR), notes/mod-fog.md
|
||||
int c = z.f()[20];
|
||||
sc.background = (c & 0xff) << 16 | (c & 0xff00) | (c >>> 16) & 0xff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int envCol = Legend.Category.ENVIRONMENT.color;
|
||||
for (int i = 0; i < map.envZones.size(); i++) {
|
||||
MapChunk.EnvZone z = map.envZones.get(i);
|
||||
if (z.isDefault()) {
|
||||
continue;
|
||||
}
|
||||
double cx = Integer.toUnsignedLong(z.f()[0]);
|
||||
double cy = Integer.toUnsignedLong(z.f()[1]);
|
||||
double cz = -Integer.toUnsignedLong(z.f()[2]);
|
||||
zones.box(cx + 0.04, cy, cz - 0.04, cx + 0.96, cy + 0.92, cz - 0.96, envCol);
|
||||
Marker mk = new Marker(
|
||||
PickId.of(PickId.ZONE, i), (float) cx + 0.5f, (float) cy + 0.46f, (float) cz - 0.5f, envCol);
|
||||
mk.layer = LAYER_ZONES;
|
||||
mk.label = "environment zone " + i;
|
||||
sc.markers.add(mk);
|
||||
}
|
||||
}
|
||||
|
||||
/** Game angle units (4096 = 360°) to radians. */
|
||||
static double angle(long units) {
|
||||
return PyMath.angle(units);
|
||||
}
|
||||
|
||||
public static TexturePage page(TextChunk.Page p) {
|
||||
int[] out = new int[p.w * p.h];
|
||||
boolean blended =
|
||||
switch (p.flags & 0x7f) {
|
||||
case 4, 5, 7 -> true;
|
||||
default -> false;
|
||||
};
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int px = p.pixels[i];
|
||||
if (p.flags == 0) {
|
||||
out[i] = px << 24 | px << 16 | px << 8 | 0xff;
|
||||
continue;
|
||||
}
|
||||
int c = TextChunk.abgr1555ToRgba(px);
|
||||
if (!blended && (c & 0xff) != 0) {
|
||||
c |= 0xff;
|
||||
}
|
||||
out[i] = c;
|
||||
}
|
||||
return new TexturePage(p.w, p.h, out);
|
||||
}
|
||||
|
||||
/** Game model to a render mesh: UVs from texture rectangles, flags, blend classes. */
|
||||
public static RenderMesh mesh(
|
||||
String name, Model m, List<TextChunk.TextureEntry> tex, TextChunk text, int[] pageClass, int extraFlags) {
|
||||
RenderMesh rm = new RenderMesh(name, m.verts.size(), m.faces.size());
|
||||
for (int i = 0; i < m.verts.size(); i++) {
|
||||
Vertex v = m.verts.get(i);
|
||||
rm.positions[3 * i] = (float) v.x();
|
||||
rm.positions[3 * i + 1] = (float) v.y();
|
||||
rm.positions[3 * i + 2] = (float) v.z();
|
||||
rm.normals[3 * i] = (float) v.nx();
|
||||
rm.normals[3 * i + 1] = (float) v.ny();
|
||||
rm.normals[3 * i + 2] = (float) v.nz();
|
||||
}
|
||||
for (int t = 0; t < m.faces.size(); t++) {
|
||||
Face f = m.faces.get(t);
|
||||
for (int k = 0; k < 3; k++) {
|
||||
int vi = f.v(k);
|
||||
rm.triangles[3 * t + k] = vi < m.verts.size() ? vi : 0;
|
||||
}
|
||||
int fl = extraFlags;
|
||||
if ((f.flags() & 0x400) != 0) {
|
||||
fl |= RenderMesh.DOUBLE_SIDED;
|
||||
}
|
||||
if ((f.flags() & 0x4000) != 0) {
|
||||
fl |= RenderMesh.NO_VERTEX_COLOR;
|
||||
}
|
||||
rm.triFlags[t] = fl;
|
||||
TextChunk.TextureEntry te = f.tex() < tex.size() ? tex.get(f.tex()) : null;
|
||||
int mode = te == null ? 0 : (te.flags() >> 12) & 7;
|
||||
if (te == null || (te.flags() & 1) != 0 || te.page() < 0 || te.page() >= text.pages.size()) {
|
||||
rm.pages[t] = -1;
|
||||
int a = te != null && mode != 0 ? te.v1() : 255;
|
||||
rm.triColors[t] = te == null ? 0xff00ffff : Legend.rgb(te.u0(), te.u1(), te.v0()) & 0xffffff00 | a;
|
||||
for (int k = 0; k < 6; k++) {
|
||||
rm.uvs[6 * t + k] = 0;
|
||||
}
|
||||
} else {
|
||||
rm.pages[t] = te.page();
|
||||
rm.triColors[t] = 0xffffffff;
|
||||
TextChunk.Page p = text.pages.get(te.page());
|
||||
double[][] uv = Geometry.faceUvs(f.flags(), Geometry.texUv(te, p.w, p.h));
|
||||
for (int k = 0; k < 3; k++) {
|
||||
rm.uvs[6 * t + 2 * k] = (float) uv[k][0];
|
||||
rm.uvs[6 * t + 2 * k + 1] = (float) uv[k][1];
|
||||
}
|
||||
if (mode == 0) {
|
||||
mode = switch (pageClass[te.page()]) {
|
||||
case 4, 7 -> 1;
|
||||
case 5 -> 2;
|
||||
default -> 0;};
|
||||
}
|
||||
}
|
||||
rm.blend[t] = switch (mode) {
|
||||
case 0 -> RenderMesh.BLEND_OPAQUE;
|
||||
case 2, 4 -> RenderMesh.BLEND_ADD;
|
||||
default -> RenderMesh.BLEND_ALPHA;};
|
||||
}
|
||||
return rm;
|
||||
}
|
||||
|
||||
/** Collision polygons of a tile model as coloured outlines (+ fill for special materials). */
|
||||
public static Overlay collision(Model m) {
|
||||
Overlay o = new Overlay();
|
||||
for (List<byte[]> g : m.coll) {
|
||||
for (byte[] r : g) {
|
||||
Collision.Decoded c = Collision.decode(r);
|
||||
double[][] pts = Collision.polygon(c);
|
||||
if (pts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
double[] n = c.plane();
|
||||
double off = 0.01;
|
||||
for (double[] p : pts) {
|
||||
p[0] += n[0] * off;
|
||||
p[1] += n[1] * off;
|
||||
p[2] += n[2] * off;
|
||||
}
|
||||
int col = Legend.collision(c.mat(), n[1]);
|
||||
for (int i = 0; i < pts.length; i++) {
|
||||
o.line(pts[i], pts[(i + 1) % pts.length], col);
|
||||
}
|
||||
int fill = c.mat() == 16 ? 110 : c.mat() == 17 || c.mat() == 18 ? 28 : 0;
|
||||
for (int i = 1; fill > 0 && i + 1 < pts.length; i++) {
|
||||
o.triangle(pts[0], pts[i], pts[i + 1], Legend.withAlpha(col, fill));
|
||||
}
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
/**
|
||||
* Pickable icon in the world: a small coloured shape, optionally on a pole, optionally with a
|
||||
* direction tick. Used for invisible objects, waypoints, lights, zones. Mutable; call {@link
|
||||
* RenderScene#changed()} after edits.
|
||||
*/
|
||||
public final class Marker {
|
||||
public enum Shape {
|
||||
CUBE,
|
||||
DIAMOND,
|
||||
ARROW
|
||||
}
|
||||
|
||||
public final int id;
|
||||
public float x;
|
||||
public float y;
|
||||
public float z;
|
||||
public int color;
|
||||
/** Half edge length in world units. */
|
||||
public float size = 0.06f;
|
||||
/** Height of the shape above the position (a pole is drawn down to it), 0 = none. */
|
||||
public float pole;
|
||||
/** Facing angle about y in radians (direction tick), {@link Float#NaN} = none. */
|
||||
public float yaw = Float.NaN;
|
||||
|
||||
public Shape shape = Shape.CUBE;
|
||||
public String layer = "markers";
|
||||
public String label;
|
||||
public boolean visible = true;
|
||||
|
||||
public Marker(int id, float x, float y, float z, int color) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.color = color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Marker shapes as one batch: coloured, slightly shaded solids plus poles and direction ticks. */
|
||||
final class MarkerMesh {
|
||||
private MarkerMesh() {}
|
||||
|
||||
private static final float[][] CUBE = {
|
||||
{-1, -1, -1}, {1, -1, -1}, {1, 1, -1}, {-1, 1, -1}, {-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1}
|
||||
};
|
||||
private static final int[] CUBE_TRIS = {
|
||||
0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2, 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5
|
||||
};
|
||||
private static final float[][] OCTA = {{1, 0, 0}, {-1, 0, 0}, {0, 1.4f, 0}, {0, -1.4f, 0}, {0, 0, 1}, {0, 0, -1}};
|
||||
private static final int[] OCTA_TRIS = {0, 2, 4, 4, 2, 1, 1, 2, 5, 5, 2, 0, 4, 3, 0, 1, 3, 4, 5, 3, 1, 0, 3, 5};
|
||||
|
||||
static Batch build(List<Marker> markers, Set<String> hidden, Batch.Builder b) {
|
||||
Overlay lines = new Overlay();
|
||||
for (Marker m : markers) {
|
||||
if (!m.visible || hidden.contains(m.layer)) {
|
||||
continue;
|
||||
}
|
||||
float cy = m.y + m.pole;
|
||||
RenderMesh mesh;
|
||||
if (m.shape == Marker.Shape.ARROW) {
|
||||
mesh = arrow(m);
|
||||
} else {
|
||||
boolean cube = m.shape == Marker.Shape.CUBE;
|
||||
mesh = solid(cube ? CUBE : OCTA, cube ? CUBE_TRIS : OCTA_TRIS, m.x, cy, m.z, m.size, m.color);
|
||||
}
|
||||
b.add(new RenderInstance(m.id, RenderInstance.Kind.OBJECT, mesh), null, false);
|
||||
if (m.pole > 0) {
|
||||
lines.line(m.x, m.y, m.z, m.x, cy, m.z, m.color);
|
||||
}
|
||||
if (!Float.isNaN(m.yaw) && m.shape != Marker.Shape.ARROW) {
|
||||
float dx = (float) Math.sin(m.yaw) * 0.3f, dz = -(float) Math.cos(m.yaw) * 0.3f;
|
||||
lines.line(m.x, cy, m.z, m.x + dx, cy, m.z + dz, m.color);
|
||||
}
|
||||
}
|
||||
b.addOverlay(lines, null);
|
||||
return b.upload(null);
|
||||
}
|
||||
|
||||
private static RenderMesh solid(float[][] v, int[] tris, float x, float y, float z, float s, int color) {
|
||||
int nt = tris.length / 3;
|
||||
RenderMesh m = new RenderMesh("marker", v.length, nt);
|
||||
for (int i = 0; i < v.length; i++) {
|
||||
m.positions[3 * i] = x + v[i][0] * s;
|
||||
m.positions[3 * i + 1] = y + v[i][1] * s;
|
||||
m.positions[3 * i + 2] = z + v[i][2] * s;
|
||||
}
|
||||
System.arraycopy(tris, 0, m.triangles, 0, tris.length);
|
||||
for (int t = 0; t < nt; t++) {
|
||||
m.pages[t] = -1;
|
||||
m.triFlags[t] = RenderMesh.DOUBLE_SIDED;
|
||||
// fake shading per face: upward faces brighter
|
||||
float[] a = v[tris[3 * t]], bb = v[tris[3 * t + 1]], c = v[tris[3 * t + 2]];
|
||||
float ny = ((bb[2] - a[2]) * (c[0] - a[0]) - (bb[0] - a[0]) * (c[2] - a[2]));
|
||||
float len = (float) Math.sqrt(sq((bb[1] - a[1]) * (c[2] - a[2]) - (bb[2] - a[2]) * (c[1] - a[1]))
|
||||
+ sq(ny)
|
||||
+ sq((bb[0] - a[0]) * (c[1] - a[1]) - (bb[1] - a[1]) * (c[0] - a[0])));
|
||||
float f = 0.7f + 0.3f * (len > 0 ? ny / len : 0);
|
||||
m.triColors[t] = Legend.dim(color, Math.max(0.45f, f)) | 0xff;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Flat arrow on the ground pointing along yaw (player start). */
|
||||
private static RenderMesh arrow(Marker mk) {
|
||||
float[][] p = {
|
||||
{-0.12f, -0.3f}, {0.12f, -0.3f}, {0.12f, 0.15f}, {-0.12f, 0.15f}, {-0.3f, 0.15f}, {0.3f, 0.15f}, {0, 0.5f}
|
||||
};
|
||||
int[] tris = {0, 1, 2, 0, 2, 3, 4, 5, 6};
|
||||
RenderMesh m = new RenderMesh("arrow", p.length, 3);
|
||||
float c = (float) Math.cos(mk.yaw), s = (float) Math.sin(mk.yaw);
|
||||
float k = mk.size / 0.3f;
|
||||
for (int i = 0; i < p.length; i++) {
|
||||
// local x = right, local y = forward
|
||||
float rx = p[i][0] * k, fw = p[i][1] * k;
|
||||
m.positions[3 * i] = mk.x + rx * c + fw * s;
|
||||
m.positions[3 * i + 1] = mk.y + 0.03f;
|
||||
m.positions[3 * i + 2] = mk.z + rx * s - fw * c;
|
||||
}
|
||||
System.arraycopy(tris, 0, m.triangles, 0, tris.length);
|
||||
for (int t = 0; t < 3; t++) {
|
||||
m.pages[t] = -1;
|
||||
m.triFlags[t] = RenderMesh.DOUBLE_SIDED;
|
||||
m.triColors[t] = mk.color;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static float sq(float f) {
|
||||
return f * f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Viewport navigation: mouse bindings per preset and the camera math (pure, unit-tested in
|
||||
* {@code NavigationTest}). Zoom is exponential (a constant percentage per wheel notch) towards a
|
||||
* robust anchor point, orbit keeps the pivot at its screen position with the pitch clamped to
|
||||
* ±89°, pan moves the point at the grab depth with the cursor.
|
||||
*/
|
||||
public final class Navigation {
|
||||
private Navigation() {}
|
||||
|
||||
public enum Preset {
|
||||
FUSION("Fusion 360"),
|
||||
BLENDER("Blender"),
|
||||
UNITY("Unity / Unreal");
|
||||
|
||||
public final String label;
|
||||
|
||||
Preset(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** Preset by enum name or label; Fusion 360 when unknown. */
|
||||
public static Preset parse(String s) {
|
||||
for (Preset p : values()) {
|
||||
if (p.name().equalsIgnoreCase(s) || p.label.equalsIgnoreCase(s)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return FUSION;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
NONE,
|
||||
ORBIT,
|
||||
PAN,
|
||||
ZOOM,
|
||||
/** Free look in place (+ WASD/QE fly while held). */
|
||||
LOOK
|
||||
}
|
||||
|
||||
/** Pitch limit of orbit and look (no flip over the poles). */
|
||||
public static final float PITCH_LIMIT = (float) Math.toRadians(89);
|
||||
|
||||
/** Camera action of a mouse button press (left without Alt is never navigation). */
|
||||
public static Action drag(Preset p, int button, boolean shift, boolean ctrl, boolean alt) {
|
||||
boolean l = button == MouseEvent.BUTTON1, m = button == MouseEvent.BUTTON2, r = button == MouseEvent.BUTTON3;
|
||||
if (l) {
|
||||
return alt ? Action.ORBIT : Action.NONE;
|
||||
}
|
||||
return switch (p) {
|
||||
case FUSION -> m
|
||||
? (shift ? Action.ORBIT : ctrl ? Action.ZOOM : Action.PAN)
|
||||
: r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE;
|
||||
case BLENDER -> m
|
||||
? (shift ? Action.PAN : ctrl ? Action.ZOOM : alt ? Action.PAN : Action.ORBIT)
|
||||
: r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE;
|
||||
case UNITY -> m
|
||||
? (ctrl ? Action.ZOOM : Action.PAN)
|
||||
: r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE;
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows {keys, action} for the help table and the status bar. */
|
||||
public static List<String[]> cheatSheet(Preset p) {
|
||||
List<String[]> rows = new ArrayList<>();
|
||||
switch (p) {
|
||||
case FUSION -> {
|
||||
rows.add(new String[] {"Middle drag", "Pan"});
|
||||
rows.add(new String[] {"Shift+middle drag", "Orbit around the point under the cursor"});
|
||||
rows.add(new String[] {"Double-click middle", "Fit all"});
|
||||
}
|
||||
case BLENDER -> {
|
||||
rows.add(new String[] {"Middle drag", "Orbit around the point under the cursor"});
|
||||
rows.add(new String[] {"Shift+middle drag", "Pan"});
|
||||
rows.add(new String[] {"Ctrl+middle drag", "Zoom"});
|
||||
}
|
||||
case UNITY -> {
|
||||
rows.add(new String[] {"Alt+left drag", "Orbit around the point under the cursor"});
|
||||
rows.add(new String[] {"Middle drag", "Pan"});
|
||||
rows.add(new String[] {"Alt+right drag", "Zoom"});
|
||||
}
|
||||
}
|
||||
rows.add(new String[] {"Mouse wheel", "Zoom towards the cursor (smooth, constant steps)"});
|
||||
rows.add(
|
||||
new String[] {"Right drag", "Look around; W A S D move, Q / E down / up, Shift faster, wheel = speed"});
|
||||
if (p != Preset.UNITY) {
|
||||
rows.add(new String[] {"Alt+left drag", "Orbit (touchpads, no middle button)"});
|
||||
}
|
||||
rows.add(new String[] {"View cube", "Click a face, edge or corner to snap; drag to orbit; house = home view"});
|
||||
rows.add(new String[] {"F / Home", "Frame the selection / the whole level"});
|
||||
rows.add(new String[] {"Numpad 7 / 1 / 3 / 5", "Top / front / side / perspective-ortho toggle"});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** One-line summary for the status bar. */
|
||||
public static String statusLine(Preset p) {
|
||||
return switch (p) {
|
||||
case FUSION -> "Fusion 360: MMB pan · Shift+MMB orbit · wheel zoom · RMB look+WASD";
|
||||
case BLENDER -> "Blender: MMB orbit · Shift+MMB pan · Ctrl+MMB zoom · RMB look+WASD";
|
||||
case UNITY -> "Unity/Unreal: RMB look+WASD · Alt+LMB orbit · MMB pan · wheel zoom";
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ math
|
||||
|
||||
/** Distance factor for wheel notches (> 0 = towards the user = out); percent per notch, e.g. 0.12. */
|
||||
public static float zoomFactor(double notches, float percent, boolean invert) {
|
||||
double p = Math.max(0.01, Math.min(0.6, percent));
|
||||
return (float) Math.pow(1 - p, -(invert ? -notches : notches));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance along the cursor ray to zoom towards: the surface hit when it is in a sane range
|
||||
* (in front of the near plane, not beyond 3× the pivot depth or the far plane), else the
|
||||
* pivot's depth along the ray, so pointing at the sky or a far mountain never makes a huge step.
|
||||
*/
|
||||
public static float anchorDistance(float hit, float pivotDepth, float near, float far) {
|
||||
float pd = Math.max(pivotDepth, 4 * near);
|
||||
if (Float.isFinite(hit) && hit > 2 * near && hit < 3 * pd && hit < 0.9f * far) {
|
||||
return hit;
|
||||
}
|
||||
return pd;
|
||||
}
|
||||
|
||||
/** Moves the camera towards/away from the point at {@code anchor} along {@code dir} by factor. */
|
||||
public static void zoom(Camera cam, Vector3f dir, float anchor, float factor) {
|
||||
float min = 2 * cam.near;
|
||||
float nd = Math.max(min, anchor * factor);
|
||||
cam.position.add(new Vector3f(dir).mul(anchor - nd));
|
||||
float cos = Math.max(0.05f, dir.dot(cam.forward()));
|
||||
// the anchor becomes the orbit pivot depth
|
||||
cam.distance = Math.max(min, nd * cos);
|
||||
}
|
||||
|
||||
/** Orthographic zoom keeping the world point under the cursor fixed. */
|
||||
public static void zoomOrtho(Camera cam, double px, double py, float factor) {
|
||||
Vector3f before = cam.ray(px, py)[0];
|
||||
cam.orthoSize = Math.max(0.05f, Math.min(5000, cam.orthoSize * factor));
|
||||
Vector3f after = cam.ray(px, py)[0];
|
||||
cam.position.add(before.sub(after));
|
||||
}
|
||||
|
||||
/**
|
||||
* Orbits about a world pivot: the camera keeps its position relative to the pivot in camera
|
||||
* space, so the pivot stays at the same screen position. Pitch is clamped to ±89°.
|
||||
*/
|
||||
public static void orbit(Camera cam, Vector3f pivot, float dYaw, float dPitch) {
|
||||
Vector3f rel = new Vector3f(cam.position).sub(pivot);
|
||||
Vector3f r0 = cam.right(), u0 = cam.up(), f0 = cam.forward();
|
||||
float rr = rel.dot(r0), ru = rel.dot(u0), rf = rel.dot(f0);
|
||||
cam.yaw = wrap(cam.yaw + dYaw);
|
||||
cam.pitch = clampPitch(cam.pitch + dPitch);
|
||||
Vector3f r1 = cam.right(), u1 = cam.up(), f1 = cam.forward();
|
||||
cam.position.set(pivot).add(r1.mul(rr)).add(u1.mul(ru)).add(f1.mul(rf));
|
||||
if (rf < 0) {
|
||||
cam.distance = Math.max(2 * cam.near, -rf);
|
||||
}
|
||||
}
|
||||
|
||||
/** Free look in place. */
|
||||
public static void look(Camera cam, float dYaw, float dPitch) {
|
||||
cam.yaw = wrap(cam.yaw + dYaw);
|
||||
cam.pitch = clampPitch(cam.pitch + dPitch);
|
||||
}
|
||||
|
||||
/** World units per pixel at a view depth. */
|
||||
public static float pixelSizeAt(Camera cam, float depth) {
|
||||
if (cam.ortho) {
|
||||
return 2 * cam.orthoSize / cam.height();
|
||||
}
|
||||
return (float) (2 * Math.max(cam.near, depth) * Math.tan(cam.fovY / 2) / cam.height());
|
||||
}
|
||||
|
||||
/** Pans so that a point at {@code depth} follows the cursor by (dx, dy) pixels. */
|
||||
public static void pan(Camera cam, float depth, float dx, float dy) {
|
||||
float px = pixelSizeAt(cam, depth);
|
||||
cam.position.add(cam.right().mul(-dx * px)).add(cam.up().mul(dy * px));
|
||||
}
|
||||
|
||||
public static float clampPitch(float p) {
|
||||
return Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, p));
|
||||
}
|
||||
|
||||
private static float wrap(float a) {
|
||||
return (float) Math.IEEEremainder(a, 2 * Math.PI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Unlit helper geometry: coloured line segments and (translucent) triangles. Colours 0xRRGGBBAA. */
|
||||
public final class Overlay {
|
||||
private float[] lines = new float[0];
|
||||
private int[] lineColors = new int[0];
|
||||
private int nLines;
|
||||
private float[] tris = new float[0];
|
||||
private int[] triColors = new int[0];
|
||||
private int nTris;
|
||||
|
||||
public void line(float ax, float ay, float az, float bx, float by, float bz, int rgba) {
|
||||
if (nLines == lineColors.length) {
|
||||
int n = Math.max(16, nLines * 2);
|
||||
lines = Arrays.copyOf(lines, n * 6);
|
||||
lineColors = Arrays.copyOf(lineColors, n);
|
||||
}
|
||||
int o = nLines * 6;
|
||||
lines[o] = ax;
|
||||
lines[o + 1] = ay;
|
||||
lines[o + 2] = az;
|
||||
lines[o + 3] = bx;
|
||||
lines[o + 4] = by;
|
||||
lines[o + 5] = bz;
|
||||
lineColors[nLines++] = rgba;
|
||||
}
|
||||
|
||||
public void line(double[] a, double[] b, int rgba) {
|
||||
line((float) a[0], (float) a[1], (float) a[2], (float) b[0], (float) b[1], (float) b[2], rgba);
|
||||
}
|
||||
|
||||
public void triangle(double[] a, double[] b, double[] c, int rgba) {
|
||||
growTris();
|
||||
int o = nTris * 9;
|
||||
double[][] p = {a, b, c};
|
||||
for (int k = 0; k < 3; k++) {
|
||||
tris[o + 3 * k] = (float) p[k][0];
|
||||
tris[o + 3 * k + 1] = (float) p[k][1];
|
||||
tris[o + 3 * k + 2] = (float) p[k][2];
|
||||
}
|
||||
triColors[nTris++] = rgba;
|
||||
}
|
||||
|
||||
private void growTris() {
|
||||
if (nTris == triColors.length) {
|
||||
int n = Math.max(16, nTris * 2);
|
||||
tris = Arrays.copyOf(tris, n * 9);
|
||||
triColors = Arrays.copyOf(triColors, n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Twelve edges of an axis-aligned box. */
|
||||
public void box(double x0, double y0, double z0, double x1, double y1, double z1, int rgba) {
|
||||
double[][] p = {
|
||||
{x0, y0, z0},
|
||||
{x1, y0, z0},
|
||||
{x1, y0, z1},
|
||||
{x0, y0, z1},
|
||||
{x0, y1, z0},
|
||||
{x1, y1, z0},
|
||||
{x1, y1, z1},
|
||||
{x0, y1, z1}
|
||||
};
|
||||
int[][] e = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
|
||||
for (int[] k : e) {
|
||||
line(p[k[0]], p[k[1]], rgba);
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends all lines and triangles of another overlay. */
|
||||
public void append(Overlay o) {
|
||||
for (int i = 0; i < o.nLines; i++) {
|
||||
int k = 6 * i;
|
||||
line(
|
||||
o.lines[k],
|
||||
o.lines[k + 1],
|
||||
o.lines[k + 2],
|
||||
o.lines[k + 3],
|
||||
o.lines[k + 4],
|
||||
o.lines[k + 5],
|
||||
o.lineColors[i]);
|
||||
}
|
||||
for (int i = 0; i < o.nTris; i++) {
|
||||
growTris();
|
||||
System.arraycopy(o.tris, 9 * i, tris, 9 * nTris, 9);
|
||||
triColors[nTris++] = o.triColors[i];
|
||||
}
|
||||
}
|
||||
|
||||
public int lineCount() {
|
||||
return nLines;
|
||||
}
|
||||
|
||||
public int triangleCount() {
|
||||
return nTris;
|
||||
}
|
||||
|
||||
/** Segment endpoints, 6 floats per line (valid up to {@link #lineCount()}). */
|
||||
public float[] lines() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
public int[] lineColors() {
|
||||
return lineColors;
|
||||
}
|
||||
|
||||
public float[] triangles() {
|
||||
return tris;
|
||||
}
|
||||
|
||||
public int[] triangleColors() {
|
||||
return triColors;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
nLines = 0;
|
||||
nTris = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
/**
|
||||
* 32-bit pick ids: kind in the top 8 bits, index in the low 24 bits. 0 means "nothing". The ids are
|
||||
* opaque to the renderer; this encoding is only a convention used by {@link LevelSceneAdapter}.
|
||||
*/
|
||||
public final class PickId {
|
||||
public static final int TILE = 1;
|
||||
public static final int OBJECT = 2;
|
||||
public static final int WAYPOINT = 3;
|
||||
public static final int ZONE = 4;
|
||||
public static final int LIGHT = 5;
|
||||
public static final int USER = 16;
|
||||
|
||||
private PickId() {}
|
||||
|
||||
public static int of(int kind, int index) {
|
||||
return kind << 24 | (index & 0xffffff);
|
||||
}
|
||||
|
||||
public static int kind(int id) {
|
||||
return id >>> 24;
|
||||
}
|
||||
|
||||
public static int index(int id) {
|
||||
return id & 0xffffff;
|
||||
}
|
||||
|
||||
public static String toString(int id) {
|
||||
if (id == 0) {
|
||||
return "none";
|
||||
}
|
||||
String k =
|
||||
switch (kind(id)) {
|
||||
case TILE -> "tile";
|
||||
case OBJECT -> "object";
|
||||
case WAYPOINT -> "waypoint";
|
||||
case ZONE -> "zone";
|
||||
case LIGHT -> "light";
|
||||
default -> "kind" + kind(id);
|
||||
};
|
||||
return k + " " + index(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Placed mesh. Instances of kind {@link Kind#TILE} are baked into static world batches (fast, many);
|
||||
* all others are drawn one by one with their transform. After changing a field call {@link
|
||||
* RenderScene#changed(RenderInstance)}.
|
||||
*/
|
||||
public final class RenderInstance {
|
||||
public enum Kind {
|
||||
TILE,
|
||||
OBJECT,
|
||||
PREVIEW
|
||||
}
|
||||
|
||||
/** Stable pick id ({@link PickId}), 0 = not pickable. */
|
||||
public final int id;
|
||||
|
||||
public final Kind kind;
|
||||
public RenderMesh mesh;
|
||||
/** Local → world (export space: y up, z ≤ 0 for the level grid). */
|
||||
public final Matrix4f transform = new Matrix4f();
|
||||
/** 0xRRGGBBAA per mesh vertex with 0x80 = 1.0 (game convention), or {@code null} = white. */
|
||||
public int[] vertexColors;
|
||||
/** Multiplied with the final colour (0xRRGGBBAA, 0xffffffff = none). */
|
||||
public int tint = 0xffffffff;
|
||||
|
||||
public boolean visible = true;
|
||||
/** Free-form group for visibility filters (e.g. "tiles", "objects"). */
|
||||
public String layer = "";
|
||||
|
||||
public RenderInstance(int id, Kind kind, RenderMesh mesh) {
|
||||
this.id = id;
|
||||
this.kind = kind;
|
||||
this.mesh = mesh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
/**
|
||||
* Triangle mesh in local space, shared by any number of {@link RenderInstance}s. Positions and
|
||||
* normals are per vertex; texture coordinates, page and flags are per triangle corner / triangle,
|
||||
* because the game assigns UVs per face (texture rectangles), not per vertex.
|
||||
*/
|
||||
public final class RenderMesh {
|
||||
/** Triangle is drawn from both sides (face flag 0x400). */
|
||||
public static final int DOUBLE_SIDED = 1;
|
||||
/** Ignore vertex colours (face flag 0x4000). */
|
||||
public static final int NO_VERTEX_COLOR = 2;
|
||||
/** Shade with the vertex normals instead of baked vertex colours (objects). */
|
||||
public static final int LIT = 4;
|
||||
|
||||
public static final int BLEND_OPAQUE = 0;
|
||||
public static final int BLEND_ALPHA = 1;
|
||||
public static final int BLEND_ADD = 2;
|
||||
|
||||
public final String name;
|
||||
/** x, y, z per vertex. */
|
||||
public final float[] positions;
|
||||
/** nx, ny, nz per vertex (may be zero). */
|
||||
public final float[] normals;
|
||||
/** Three vertex indices per triangle. */
|
||||
public final int[] triangles;
|
||||
/** u, v per triangle corner (6 per triangle), normalised to the page. */
|
||||
public final float[] uvs;
|
||||
/** Texture page per triangle, -1 = untextured (flat colour only). */
|
||||
public final int[] pages;
|
||||
/** 0xRRGGBBAA per triangle, multiplied with the texel (flat colours, translucency). */
|
||||
public final int[] triColors;
|
||||
/** Bit set of {@link #DOUBLE_SIDED}, {@link #NO_VERTEX_COLOR}, {@link #LIT} per triangle. */
|
||||
public final int[] triFlags;
|
||||
/** {@link #BLEND_OPAQUE}, {@link #BLEND_ALPHA} or {@link #BLEND_ADD} per triangle. */
|
||||
public final int[] blend;
|
||||
/** Optional local-space helper geometry that follows every instance (collision wireframe). */
|
||||
public Overlay overlay;
|
||||
|
||||
public RenderMesh(String name, int vertexCount, int triangleCount) {
|
||||
this.name = name;
|
||||
positions = new float[vertexCount * 3];
|
||||
normals = new float[vertexCount * 3];
|
||||
triangles = new int[triangleCount * 3];
|
||||
uvs = new float[triangleCount * 6];
|
||||
pages = new int[triangleCount];
|
||||
triColors = new int[triangleCount];
|
||||
triFlags = new int[triangleCount];
|
||||
blend = new int[triangleCount];
|
||||
}
|
||||
|
||||
public int vertexCount() {
|
||||
return positions.length / 3;
|
||||
}
|
||||
|
||||
public int triangleCount() {
|
||||
return pages.length;
|
||||
}
|
||||
|
||||
/** Local bounding box {minX, minY, minZ, maxX, maxY, maxZ}; zero box when empty. */
|
||||
public float[] bounds() {
|
||||
if (positions.length == 0) {
|
||||
return new float[6];
|
||||
}
|
||||
float[] b = Culling.emptyBox();
|
||||
for (int i = 0; i < positions.length; i += 3) {
|
||||
Culling.grow(b, positions[i], positions[i + 1], positions[i + 2]);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Everything the viewport draws. Plain mutable data; the renderer caches GPU buffers and rebuilds
|
||||
* them after {@link #changed()} (everything), {@link #changed(RenderInstance)} (one instance) or
|
||||
* {@link #texturesChanged()}. Not thread-safe: modify on the Swing thread.
|
||||
*/
|
||||
public final class RenderScene {
|
||||
public final List<TexturePage> pages = new ArrayList<>();
|
||||
public final List<RenderInstance> instances = new ArrayList<>();
|
||||
public final List<Marker> markers = new ArrayList<>();
|
||||
/** World-space overlays by layer name (e.g. "waypoints", "zones"). */
|
||||
public final Map<String, Overlay> overlays = new LinkedHashMap<>();
|
||||
/** Background colour 0xRRGGBB. */
|
||||
public int background = 0x5d7186;
|
||||
|
||||
private int version;
|
||||
private int textureVersion;
|
||||
private final Set<RenderInstance> dirty = new HashSet<>();
|
||||
private boolean allDirty = true;
|
||||
|
||||
public void changed() {
|
||||
allDirty = true;
|
||||
version++;
|
||||
}
|
||||
|
||||
public void changed(RenderInstance inst) {
|
||||
dirty.add(inst);
|
||||
version++;
|
||||
}
|
||||
|
||||
/** Only markers and overlays changed (cheap: no chunk is rebuilt). */
|
||||
public void overlaysChanged() {
|
||||
version++;
|
||||
}
|
||||
|
||||
public void texturesChanged() {
|
||||
textureVersion++;
|
||||
}
|
||||
|
||||
public int version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public int textureVersion() {
|
||||
return textureVersion;
|
||||
}
|
||||
|
||||
/** Taken by the renderer: {@code null} means rebuild everything. */
|
||||
Set<RenderInstance> takeDirty() {
|
||||
if (allDirty) {
|
||||
allDirty = false;
|
||||
dirty.clear();
|
||||
return null;
|
||||
}
|
||||
Set<RenderInstance> d = new HashSet<>(dirty);
|
||||
dirty.clear();
|
||||
return d;
|
||||
}
|
||||
|
||||
public RenderInstance instance(int id) {
|
||||
for (RenderInstance i : instances) {
|
||||
if (i.id == id) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Marker marker(int id) {
|
||||
for (Marker m : markers) {
|
||||
if (m.id == id) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
/** GLSL 3.30 core sources. */
|
||||
final class Shaders {
|
||||
private Shaders() {}
|
||||
|
||||
static final String MESH_VS =
|
||||
"""
|
||||
#version 330 core
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in vec2 aUv;
|
||||
layout(location = 3) in vec4 aColor;
|
||||
layout(location = 4) in vec4 aId;
|
||||
layout(location = 5) in ivec2 aLayerFlags;
|
||||
uniform mat4 uViewProj;
|
||||
uniform mat4 uModel;
|
||||
out vec2 vUv;
|
||||
out vec4 vColor;
|
||||
out vec3 vNormal;
|
||||
out vec3 vWorld;
|
||||
flat out vec4 vId;
|
||||
flat out int vLayer;
|
||||
flat out int vFlags;
|
||||
void main() {
|
||||
vec4 w = uModel * vec4(aPos, 1.0);
|
||||
vWorld = w.xyz;
|
||||
gl_Position = uViewProj * w;
|
||||
vUv = aUv;
|
||||
vColor = aColor;
|
||||
vNormal = mat3(uModel) * aNormal;
|
||||
vId = aId;
|
||||
vLayer = aLayerFlags.x;
|
||||
vFlags = aLayerFlags.y;
|
||||
}
|
||||
""";
|
||||
|
||||
static final String MESH_FS =
|
||||
"""
|
||||
#version 330 core
|
||||
in vec2 vUv;
|
||||
in vec4 vColor;
|
||||
in vec3 vNormal;
|
||||
in vec3 vWorld;
|
||||
flat in vec4 vId;
|
||||
flat in int vLayer;
|
||||
flat in int vFlags;
|
||||
uniform sampler2DArray uTex;
|
||||
uniform int uPass; // 0 opaque, 1 blended, 2 pick, 3 back sides of one-sided faces
|
||||
uniform vec4 uTint;
|
||||
uniform vec4 uPickId;
|
||||
uniform int uUsePickId;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec2 uFog; // start, end (end <= start: off)
|
||||
uniform vec4 uHighlight; // rgb, amount
|
||||
uniform vec4 uFogMode; // view direction (0 = radial distance), max amount
|
||||
uniform vec3 uFogOrigin; // distances are measured from here (normally the eye)
|
||||
uniform float uFarClip; // > 0: discard beyond this view distance
|
||||
uniform int uClipGhost; // 1: beyond uFarClip draw a faded ghost instead of discarding
|
||||
uniform float uBackAlpha; // opacity of back sides (pass 3)
|
||||
uniform int uShade; // 0 texture × colour, 1 vertex colours only
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
vec4 t = texture(uTex, vec3(vUv, float(vLayer)));
|
||||
if (uPass != 1) {
|
||||
if (t.a < 0.5) discard;
|
||||
t.a = 1.0;
|
||||
}
|
||||
bool back = !gl_FrontFacing && (vFlags & 1) == 0;
|
||||
if (uPass == 2) {
|
||||
fragColor = uUsePickId != 0 ? uPickId : vId;
|
||||
// bit 31 of the id (unused by pick ids) marks the back side of a one-sided face
|
||||
if (back) fragColor.a = min(fragColor.a + 128.0 / 255.0, 1.0);
|
||||
return;
|
||||
}
|
||||
float dist = dot(uFogMode.xyz, uFogMode.xyz) > 0.0
|
||||
? dot(vWorld - uFogOrigin, uFogMode.xyz) : distance(vWorld, uFogOrigin);
|
||||
bool beyond = uFarClip > 0.0 && dist > uFarClip;
|
||||
if (beyond && uClipGhost == 0) discard;
|
||||
if (uShade == 1) t.rgb = vec3(1.0);
|
||||
vec4 c = t * vColor * uTint;
|
||||
if ((vFlags & 4) != 0) {
|
||||
vec3 n = normalize(vNormal);
|
||||
float l = 0.62 + 0.38 * max(dot(n, normalize(vec3(0.35, 0.85, 0.4))), 0.0)
|
||||
+ 0.12 * max(dot(n, normalize(vec3(-0.5, 0.2, -0.6))), 0.0);
|
||||
c.rgb *= l;
|
||||
}
|
||||
if (uPass == 1 && c.a < 0.004) discard;
|
||||
c.rgb = mix(c.rgb, uHighlight.rgb, uHighlight.a);
|
||||
if (beyond) {
|
||||
// beyond the previewed far clip: a desaturated ghost instead of full fog colour
|
||||
float g = dot(c.rgb, vec3(0.3, 0.59, 0.11));
|
||||
c.rgb = mix(mix(c.rgb, vec3(g), 0.6), uFogColor, 0.45);
|
||||
c.a *= 0.55;
|
||||
} else if (uFog.y > uFog.x) {
|
||||
float f = clamp((dist - uFog.x) / (uFog.y - uFog.x), 0.0, 1.0);
|
||||
c.rgb = mix(c.rgb, uFogColor, f * uFogMode.w);
|
||||
}
|
||||
if (uPass == 3) {
|
||||
// back side: darkened, desaturated, translucent, fine diagonal hatch (screen space)
|
||||
float g = dot(c.rgb, vec3(0.3, 0.59, 0.11));
|
||||
vec3 d = mix(vec3(g), c.rgb, 0.3) * 0.5;
|
||||
float stripe = 1.0 - step(1.5, mod(gl_FragCoord.x + gl_FragCoord.y, 6.0));
|
||||
d = mix(d, min(d * 1.9 + 0.1, vec3(1.0)), stripe * 0.6);
|
||||
c = vec4(d, clamp(uBackAlpha * (1.0 + 0.5 * stripe), 0.0, 1.0));
|
||||
}
|
||||
fragColor = c;
|
||||
}
|
||||
""";
|
||||
|
||||
static final String LINE_VS =
|
||||
"""
|
||||
#version 330 core
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec4 aColor;
|
||||
uniform mat4 uViewProj;
|
||||
uniform mat4 uModel;
|
||||
out vec4 vColor;
|
||||
void main() {
|
||||
gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
|
||||
vColor = aColor;
|
||||
}
|
||||
""";
|
||||
|
||||
static final String LINE_FS =
|
||||
"""
|
||||
#version 330 core
|
||||
in vec4 vColor;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
fragColor = vColor;
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Model;
|
||||
import com.eliasmueller.llamamod.format.Stpc;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.Trak;
|
||||
import com.eliasmueller.llamamod.format.Vertex;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* STPC object models and which one a strat type shows. The strat bytecode sets its model with
|
||||
* {@code 0xb2 <abs STPC offset of the model header>; 0x54} (push address, set model); the first such
|
||||
* pair inside the strat's code is taken. Spawn stubs ({@code b2 <code> e0}) are followed to their
|
||||
* target. Characters set skinned models through other opcodes (0x41 with animation sets) and are
|
||||
* not resolved.
|
||||
*/
|
||||
public final class StratModels {
|
||||
private static final int OP_PUSH_ADDR = 0xb2;
|
||||
private static final int OP_SET_MODEL = 0x54;
|
||||
private static final int OP_SPAWN = 0xe0;
|
||||
private static final int SCAN_LIMIT = 2048;
|
||||
|
||||
public final List<Model> models = new ArrayList<>();
|
||||
|
||||
private final byte[] stpc;
|
||||
private final int codeStart;
|
||||
private final int[] nameSites;
|
||||
private final Map<Integer, Integer> byHeader = new HashMap<>();
|
||||
private final Map<Integer, Integer> cache = new HashMap<>();
|
||||
/** Animation set header offsets (FUN_0041f8b0 layout, see {@link Stpc}). */
|
||||
private final List<Integer> animSets = new ArrayList<>();
|
||||
|
||||
public StratModels(byte[] stpc, int wfpc) {
|
||||
this.stpc = stpc;
|
||||
Stpc.Layout lay = Stpc.walk(stpc, wfpc);
|
||||
codeStart = lay.codeStart();
|
||||
for (Stpc.ModelSpan s : lay.models()) {
|
||||
add(
|
||||
s.header(),
|
||||
Trak.parseModels(stpc, s.header(), 1, Trak.STPC_HEADER)
|
||||
.models()
|
||||
.get(0));
|
||||
}
|
||||
for (Stpc.Extra e : lay.extra()) {
|
||||
if (e.models().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<Model> mm = Trak.parseModels(
|
||||
stpc, e.models().get(0).header(), e.models().size(), Trak.STPC_HEADER)
|
||||
.models();
|
||||
for (int i = 0; i < mm.size(); i++) {
|
||||
add(e.models().get(i).header(), mm.get(i));
|
||||
}
|
||||
}
|
||||
int o = lay.models().isEmpty()
|
||||
? 4
|
||||
: lay.models().get(lay.models().size() - 1).end();
|
||||
int na = Bytes.u32(stpc, o);
|
||||
o += 4;
|
||||
for (int i = 0; i < na && o + 0x20 <= stpc.length; i++) {
|
||||
animSets.add(o);
|
||||
o = skipAnimSet(o);
|
||||
}
|
||||
nameSites = Strats.allNamed(stpc, codeStart).stream()
|
||||
.mapToInt(Strats.Named::offset)
|
||||
.sorted()
|
||||
.toArray();
|
||||
}
|
||||
|
||||
private void add(int header, Model m) {
|
||||
byHeader.put(header, models.size());
|
||||
models.add(m);
|
||||
}
|
||||
|
||||
private int skipAnimSet(int h) {
|
||||
int nfr = Bytes.u32(stpc, h + 8);
|
||||
int a4 = Bytes.u32(stpc, h + 16);
|
||||
int a6 = Bytes.u32(stpc, h + 24);
|
||||
int o = firstMatrices(h);
|
||||
if (a4 != 0) {
|
||||
o += nfr * 4;
|
||||
}
|
||||
for (int f = 0; f < nfr; f++) {
|
||||
if (a4 != 0) {
|
||||
o += f == 0 ? a4 * 8 : a4 * 2 + ((a4 & 1) != 0 ? 2 : 0);
|
||||
}
|
||||
o += a6 * 0x40;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/** Offset of the first bone matrix of frame 0 of an animation set (only sets without vertex deltas). */
|
||||
private int firstMatrices(int h) {
|
||||
int a0 = Bytes.u32(stpc, h);
|
||||
int nfr = Bytes.u32(stpc, h + 8);
|
||||
int a3 = Bytes.i32(stpc, h + 12);
|
||||
int o = h + 0x20 + a0 * 4;
|
||||
if (a3 == 0) {
|
||||
o += nfr * 8;
|
||||
}
|
||||
return o + nfr * 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* A skinned model posed with frame 0 of the first animation set referenced by the strat code that
|
||||
* has the model's bone count (else any such set), or {@code null}. Bone matrices are 4×4 f32,
|
||||
* row vectors (D3D): p' = p·M.
|
||||
*/
|
||||
public Model posed(int entry, int modelIndex) {
|
||||
Model m = models.get(modelIndex);
|
||||
int bones = m.extra.length / 2;
|
||||
if (!skinned(m) || bones == 0) {
|
||||
return null;
|
||||
}
|
||||
int set = -1;
|
||||
int end = Math.min(stpc.length - 12, entry + SCAN_LIMIT * 2);
|
||||
for (int o = entry; o >= codeStart && o < end && set < 0; o += 4) {
|
||||
if ((Bytes.u32L(stpc, o) & 0xffff) == OP_PUSH_ADDR) {
|
||||
int a = Bytes.i32(stpc, o + 4);
|
||||
if (animSets.contains(a) && Bytes.u32(stpc, a + 24) == bones && Bytes.u32(stpc, a + 16) == 0) {
|
||||
set = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (set < 0) {
|
||||
for (int a : animSets) {
|
||||
if (Bytes.u32(stpc, a + 24) == bones && Bytes.u32(stpc, a + 16) == 0 && Bytes.u32(stpc, a + 8) > 0) {
|
||||
set = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (set < 0) {
|
||||
return null;
|
||||
}
|
||||
int mo = firstMatrices(set);
|
||||
Model out = new Model();
|
||||
out.faces.addAll(m.faces);
|
||||
int v = 0;
|
||||
for (int b = 0; b < bones; b++) {
|
||||
int cnt = Bytes.u16(m.extra, 2 * b);
|
||||
float[] r = new float[16];
|
||||
for (int k = 0; k < 16; k++) {
|
||||
r[k] = (float) Bytes.f32(stpc, mo + b * 0x40 + 4 * k);
|
||||
}
|
||||
for (int k = 0; k < cnt && v < m.verts.size(); k++, v++) {
|
||||
Vertex p = m.verts.get(v);
|
||||
double x = p.x(), y = p.y(), z = p.z();
|
||||
double nx = p.nx(), ny = p.ny(), nz = p.nz();
|
||||
out.verts.add(new Vertex(
|
||||
x * r[0] + y * r[4] + z * r[8] + r[12],
|
||||
x * r[1] + y * r[5] + z * r[9] + r[13],
|
||||
x * r[2] + y * r[6] + z * r[10] + r[14],
|
||||
nx * r[0] + ny * r[4] + nz * r[8],
|
||||
nx * r[1] + ny * r[5] + nz * r[9],
|
||||
nx * r[2] + ny * r[6] + nz * r[10]));
|
||||
}
|
||||
}
|
||||
while (out.verts.size() < m.verts.size()) {
|
||||
out.verts.add(m.verts.get(out.verts.size()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int codeStart() {
|
||||
return codeStart;
|
||||
}
|
||||
|
||||
/** Skinned (boned) models store bone-relative vertices; they are not drawn as rigid meshes. */
|
||||
public static boolean skinned(Model m) {
|
||||
return m.extra.length > 0 && m.x84 == 0;
|
||||
}
|
||||
|
||||
/** Model index shown by the strat whose code starts at entry, or -1. */
|
||||
public int modelOf(int entry) {
|
||||
Integer m = cache.get(entry);
|
||||
if (m == null) {
|
||||
cache.put(entry, -1);
|
||||
m = resolve(entry);
|
||||
cache.put(entry, m);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private int resolve(int entry) {
|
||||
if (entry < codeStart || entry + 32 > stpc.length) {
|
||||
return -1;
|
||||
}
|
||||
for (int k = 0; k < 5; k++) {
|
||||
int o = entry + 4 * k;
|
||||
if (Bytes.u32L(stpc, o) == OP_PUSH_ADDR && Bytes.u32L(stpc, o + 8) == OP_SPAWN) {
|
||||
int target = Bytes.i32(stpc, o + 4);
|
||||
return target != entry ? modelOf(target) : -1;
|
||||
}
|
||||
}
|
||||
int end = Math.min(stpc.length - 12, entry + SCAN_LIMIT);
|
||||
// stop before the next strat: its name site lies a few words behind its first opcode
|
||||
int own = -1;
|
||||
for (int s : nameSites) {
|
||||
if (own < 0 && s >= entry) {
|
||||
own = s;
|
||||
} else if (own >= 0 && s > own) {
|
||||
end = Math.min(end, s - 16);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int o = entry; o < end; o += 4) {
|
||||
if ((Bytes.u32L(stpc, o) & 0xffff) == OP_PUSH_ADDR && (Bytes.u32L(stpc, o + 8) & 0xffff) == OP_SET_MODEL) {
|
||||
Integer m = byHeader.get(Bytes.i32(stpc, o + 4));
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
/**
|
||||
* Texture page: w·h pixels 0xRRGGBBAA, row-major from the top. Alpha 0 is transparent (cut out),
|
||||
* alpha below 255 is translucent on blended triangles.
|
||||
*/
|
||||
public record TexturePage(int width, int height, int[] rgba) {}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
import org.joml.Vector4f;
|
||||
|
||||
/**
|
||||
* Orientation cube in the top-right viewport corner (Fusion 360 style). Faces are named after the
|
||||
* game views: TOP = +y, FRONT = export −z (the front view looks along game −z), RIGHT =
|
||||
* +x. Clicking a face, edge or corner region snaps the view to look from that direction at the
|
||||
* pivot; the house button above it goes home. Pure geometry/hit testing; the viewport draws it.
|
||||
*/
|
||||
public final class ViewCube {
|
||||
private ViewCube() {}
|
||||
|
||||
/** Geometry in cube space ([-1, 1]³), its matrix and the GL viewport rect (framebuffer pixels). */
|
||||
public record Frame(
|
||||
Overlay lines, Overlay tris, Matrix4f matrix, int x, int y, int size, Overlay hud, Matrix4f hudMatrix) {}
|
||||
|
||||
/** A region: direction from the cube centre (components -1/0/1), or the home button. */
|
||||
public record Hit(int dx, int dy, int dz, boolean home) {
|
||||
static final Hit HOME = new Hit(0, 0, 0, true);
|
||||
|
||||
public Vector3f direction() {
|
||||
return new Vector3f(dx, dy, dz).normalize();
|
||||
}
|
||||
}
|
||||
|
||||
public static final int SIZE = 108;
|
||||
public static final int MARGIN = 10;
|
||||
static final int HOME = 22;
|
||||
/** Face cells: |c| beyond this belongs to an edge/corner region. */
|
||||
static final float SPLIT = 0.56f;
|
||||
|
||||
/** Cube rect in component pixels {x, y, size}. */
|
||||
public static int[] rect(int width) {
|
||||
return new int[] {width - MARGIN - SIZE, MARGIN + HOME, SIZE};
|
||||
}
|
||||
|
||||
/** Home button rect {x, y, w, h} (component pixels), left above the cube. */
|
||||
public static int[] homeRect(int width) {
|
||||
int[] r = rect(width);
|
||||
return new int[] {r[0] + 4, MARGIN - 4, HOME, HOME};
|
||||
}
|
||||
|
||||
/** Rotation-only view (same handedness as the main view) with a fixed orthographic lens. */
|
||||
public static Matrix4f matrix(Camera cam) {
|
||||
Matrix4f v = cam.viewMatrix();
|
||||
v.setTranslation(0, 0, 0);
|
||||
return new Matrix4f().ortho(-1.8f, 1.8f, -1.8f, 1.8f, -10, 10).mul(v);
|
||||
}
|
||||
|
||||
/** Region under a component pixel, or null. */
|
||||
public static Hit hit(Camera cam, int width, int mx, int my) {
|
||||
int[] h = homeRect(width);
|
||||
if (mx >= h[0] && mx < h[0] + h[2] && my >= h[1] && my < h[1] + h[3]) {
|
||||
return Hit.HOME;
|
||||
}
|
||||
int[] r = rect(width);
|
||||
if (mx < r[0] || my < r[1] || mx >= r[0] + r[2] || my >= r[1] + r[2]) {
|
||||
return null;
|
||||
}
|
||||
float nx = 2f * (mx - r[0] + 0.5f) / r[2] - 1, ny = 1 - 2f * (my - r[1] + 0.5f) / r[2];
|
||||
Matrix4f inv = matrix(cam).invert();
|
||||
Vector4f a = new Vector4f(nx, ny, -1, 1).mul(inv);
|
||||
Vector4f b = new Vector4f(nx, ny, 1, 1).mul(inv);
|
||||
Vector3f o = new Vector3f(a.x, a.y, a.z).div(a.w);
|
||||
Vector3f d = new Vector3f(b.x, b.y, b.z).div(b.w).sub(o);
|
||||
// slab test against [-1, 1]^3, nearest entry
|
||||
float t0 = -Float.MAX_VALUE, t1 = Float.MAX_VALUE;
|
||||
int axis = -1;
|
||||
for (int k = 0; k < 3; k++) {
|
||||
float ok = o.get(k), dk = d.get(k);
|
||||
if (Math.abs(dk) < 1e-9f) {
|
||||
if (ok < -1 || ok > 1) {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
float ta = (-1 - ok) / dk, tb = (1 - ok) / dk;
|
||||
float lo = Math.min(ta, tb), hi = Math.max(ta, tb);
|
||||
if (lo > t0) {
|
||||
t0 = lo;
|
||||
axis = k;
|
||||
}
|
||||
t1 = Math.min(t1, hi);
|
||||
}
|
||||
if (axis < 0 || t0 > t1) {
|
||||
return null;
|
||||
}
|
||||
Vector3f p = new Vector3f(d).mul(t0).add(o);
|
||||
int[] c = new int[3];
|
||||
for (int k = 0; k < 3; k++) {
|
||||
float v = p.get(k);
|
||||
c[k] = k == axis ? (v > 0 ? 1 : -1) : v > SPLIT ? 1 : v < -SPLIT ? -1 : 0;
|
||||
}
|
||||
return new Hit(c[0], c[1], c[2], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera angles looking from direction (dx, dy, dz) towards the pivot: {yaw, pitch}. Straight
|
||||
* down/up keeps the top view's orientation (x right, game z downwards).
|
||||
*/
|
||||
public static float[] angles(Hit h) {
|
||||
Vector3f f = h.direction().negate();
|
||||
float pitch = (float) Math.asin(Math.max(-1, Math.min(1, f.y)));
|
||||
float yaw = Math.abs(f.x) + Math.abs(f.z) < 1e-6 ? (float) Math.PI : (float) Math.atan2(f.x, -f.z);
|
||||
if (Math.abs(pitch) > Math.PI / 2 - 1e-3) {
|
||||
pitch = (float) Math.copySign(Math.PI / 2, pitch);
|
||||
}
|
||||
return new float[] {yaw, pitch};
|
||||
}
|
||||
|
||||
private static final int FACE = Legend.withAlpha(Legend.rgb(222, 226, 233), 238);
|
||||
private static final int FACE_HOVER = Legend.withAlpha(Legend.rgb(120, 170, 245), 245);
|
||||
private static final int EDGE = Legend.withAlpha(Legend.rgb(70, 76, 88), 255);
|
||||
private static final int GRID = Legend.withAlpha(Legend.rgb(150, 156, 168), 140);
|
||||
private static final int TEXT = Legend.withAlpha(Legend.rgb(40, 44, 54), 255);
|
||||
|
||||
/** Face table: normal axis, sign, text right axis/sign, text up axis/sign, label. */
|
||||
private static final Object[][] FACES = {
|
||||
{1, 1, 0, 1, 2, 1, "TOP"},
|
||||
{1, -1, 0, 1, 2, -1, "BOTTOM"},
|
||||
{2, -1, 0, 1, 1, 1, "FRONT"},
|
||||
{2, 1, 0, -1, 1, 1, "BACK"},
|
||||
{0, 1, 2, 1, 1, 1, "RIGHT"},
|
||||
{0, -1, 2, -1, 1, 1, "LEFT"},
|
||||
};
|
||||
|
||||
/** Builds the frame (framebuffer pixels: scale = framebuffer / component). */
|
||||
public static Frame build(Camera cam, int compWidth, int compHeight, float scale, Hit hover) {
|
||||
Overlay lines = new Overlay(), tris = new Overlay();
|
||||
float[] cuts = {-1, -SPLIT, SPLIT, 1};
|
||||
for (Object[] f : FACES) {
|
||||
int n = (int) f[0], ns = (int) f[1], ra = (int) f[2], rs = (int) f[3], ua = (int) f[4], us = (int) f[5];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
for (int j = 0; j < 3; j++) {
|
||||
int[] reg = new int[3];
|
||||
reg[n] = ns;
|
||||
reg[ra] = rs * (i - 1);
|
||||
reg[ua] = us * (j - 1);
|
||||
boolean hl = hover != null
|
||||
&& !hover.home()
|
||||
&& hover.dx() == reg[0]
|
||||
&& hover.dy() == reg[1]
|
||||
&& hover.dz() == reg[2];
|
||||
double[][] q = new double[4][];
|
||||
for (int k = 0; k < 4; k++) {
|
||||
double[] p = new double[3];
|
||||
p[n] = ns;
|
||||
p[ra] = rs * cuts[i + ((k == 1 || k == 2) ? 1 : 0)];
|
||||
p[ua] = us * cuts[j + (k >= 2 ? 1 : 0)];
|
||||
q[k] = p;
|
||||
}
|
||||
int col = hl ? FACE_HOVER : FACE;
|
||||
tris.triangle(q[0], q[1], q[2], col);
|
||||
tris.triangle(q[0], q[2], q[3], col);
|
||||
}
|
||||
}
|
||||
for (int k = 1; k <= 2; k++) {
|
||||
double[] a = new double[3], b = new double[3], c = new double[3], d = new double[3];
|
||||
a[n] = b[n] = c[n] = d[n] = ns * 1.001;
|
||||
a[ra] = b[ra] = cuts[k];
|
||||
a[ua] = -1;
|
||||
b[ua] = 1;
|
||||
c[ua] = d[ua] = cuts[k];
|
||||
c[ra] = -1;
|
||||
d[ra] = 1;
|
||||
lines.line(a, b, GRID);
|
||||
lines.line(c, d, GRID);
|
||||
}
|
||||
text(lines, (String) f[6], n, ns, ra, rs, ua, us);
|
||||
}
|
||||
for (int a = 0; a < 3; a++) {
|
||||
for (int s1 = -1; s1 <= 1; s1 += 2) {
|
||||
for (int s2 = -1; s2 <= 1; s2 += 2) {
|
||||
double[] p = new double[3], q = new double[3];
|
||||
int b = (a + 1) % 3, c = (a + 2) % 3;
|
||||
p[a] = -1;
|
||||
q[a] = 1;
|
||||
p[b] = q[b] = s1 * 1.002;
|
||||
p[c] = q[c] = s2 * 1.002;
|
||||
lines.line(p, q, EDGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
int[] r = rect(compWidth);
|
||||
int size = Math.round(r[2] * scale);
|
||||
int x = Math.round(r[0] * scale);
|
||||
int y = Math.round((compHeight - r[1] - r[2]) * scale);
|
||||
Overlay hud = homeButton(compWidth, hover != null && hover.home());
|
||||
Matrix4f hm = new Matrix4f().ortho(0, compWidth, compHeight, 0, -1, 1);
|
||||
return new Frame(lines, tris, matrix(cam), x, y, size, hud, hm);
|
||||
}
|
||||
|
||||
/** Home button (house glyph) in component pixels; pixel centres so axis-aligned lines rasterise. */
|
||||
private static Overlay homeButton(int width, boolean hl) {
|
||||
Overlay o = new Overlay();
|
||||
int[] r = homeRect(width);
|
||||
double x0 = r[0], y0 = r[1], x1 = r[0] + r[2], y1 = r[1] + r[3];
|
||||
int bg = hl ? FACE_HOVER : Legend.withAlpha(Legend.rgb(222, 226, 233), 215);
|
||||
o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y0, 0}, new double[] {x1, y1, 0}, bg);
|
||||
o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y1, 0}, new double[] {x0, y1, 0}, bg);
|
||||
float cx = (float) (x0 + x1) / 2 + 0.5f, s = r[2] / 22f, hy = (float) y0 + 0.5f;
|
||||
float[][] p = {
|
||||
{cx - 7 * s, hy + 11 * s}, {cx, hy + 4 * s}, {cx + 7 * s, hy + 11 * s},
|
||||
{cx + 5 * s, hy + 9.5f * s}, {cx + 5 * s, hy + 18 * s}, {cx - 5 * s, hy + 18 * s},
|
||||
{cx - 5 * s, hy + 9.5f * s}
|
||||
};
|
||||
int[][] seg = {{0, 1}, {1, 2}, {3, 4}, {4, 5}, {5, 6}};
|
||||
for (int[] g : seg) {
|
||||
o.line(p[g[0]][0], p[g[0]][1], 0, p[g[1]][0], p[g[1]][1], 0, TEXT);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ stroke font
|
||||
|
||||
/** Polylines per letter in a 0..1 × 0..1.4 box, pen-up between strokes (NaN). */
|
||||
private static final String[] GLYPHS = {
|
||||
"T 0,1.4 1,1.4 N .5,1.4 .5,0",
|
||||
"O 0,0 1,0 1,1.4 0,1.4 0,0",
|
||||
"P 0,0 0,1.4 1,1.4 1,.7 0,.7",
|
||||
"B 0,0 0,1.4 .8,1.4 .8,.7 0,.7 N .8,.7 1,.6 1,0 0,0",
|
||||
"M 0,0 0,1.4 .5,.7 1,1.4 1,0",
|
||||
"F 0,0 0,1.4 1,1.4 N 0,.7 .75,.7",
|
||||
"R 0,0 0,1.4 1,1.4 1,.7 0,.7 1,0",
|
||||
"N 0,0 0,1.4 1,0 1,1.4",
|
||||
"A 0,0 0,1.4 1,1.4 1,0 N 0,.7 1,.7",
|
||||
"C 1,0 0,0 0,1.4 1,1.4",
|
||||
"K 0,0 0,1.4 N 1,1.4 0,.7 1,0",
|
||||
"L 0,1.4 0,0 1,0",
|
||||
"E 1,0 0,0 0,1.4 1,1.4 N 0,.7 .75,.7",
|
||||
"I .5,0 .5,1.4",
|
||||
"G 1,1.4 0,1.4 0,0 1,0 1,.7 .5,.7",
|
||||
"H 0,0 0,1.4 N 1,0 1,1.4 N 0,.7 1,.7",
|
||||
};
|
||||
|
||||
private static void text(Overlay o, String s, int n, int ns, int ra, int rs, int ua, int us) {
|
||||
float h = 0.32f, w = 0.18f, gap = 0.07f;
|
||||
float total = s.length() * w + (s.length() - 1) * gap;
|
||||
float x0 = -total / 2, y0 = -h / 2;
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
String g = glyph(s.charAt(i));
|
||||
if (g == null) {
|
||||
continue;
|
||||
}
|
||||
double[] prev = null;
|
||||
for (String tok : g.substring(2).split(" ")) {
|
||||
if (tok.equals("N")) {
|
||||
prev = null;
|
||||
continue;
|
||||
}
|
||||
String[] xy = tok.split(",");
|
||||
float gx = x0 + i * (w + gap) + Float.parseFloat(xy[0]) * w;
|
||||
float gy = y0 + Float.parseFloat(xy[1]) / 1.4f * h;
|
||||
double[] p = new double[3];
|
||||
p[n] = ns * 1.004;
|
||||
p[ra] = rs * gx;
|
||||
p[ua] = us * gy;
|
||||
if (prev != null) {
|
||||
// twice, half a pixel apart: bolder, and never lost on a pixel edge
|
||||
o.line(prev, p, TEXT);
|
||||
double[] a = prev.clone(), b = p.clone();
|
||||
a[ua] += 0.016;
|
||||
b[ua] += 0.016;
|
||||
a[ra] += 0.016;
|
||||
b[ra] += 0.016;
|
||||
o.line(a, b, TEXT);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String glyph(char c) {
|
||||
for (String g : GLYPHS) {
|
||||
if (g.charAt(0) == c) {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.mods.testworld.TestWorld;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Standalone level viewer for testing the viewport.
|
||||
*
|
||||
* <pre>
|
||||
* ViewerDemo <level.wad | testworld> [--cd DIR] [--size WxH] [--collision] [--no-markers]
|
||||
* [--view top|front|side] [--cam x,y,z,yawDeg,pitchDeg] [--fov vDeg] [--select ID[,ID]] [--frame]
|
||||
* [--gizmo translate|rotate|scale] [--shot out.png] [--bench]
|
||||
* </pre>
|
||||
*
|
||||
* IDs: {@code o12} = strat 12, {@code t5} = tile 5, {@code w3} = waypoint 3. With --shot the
|
||||
* window renders once, saves and exits; --bench prints frame times while orbiting.
|
||||
*/
|
||||
public final class ViewerDemo {
|
||||
private ViewerDemo() {}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length == 0) {
|
||||
System.err.println("usage: ViewerDemo <level.wad|testworld> [options]");
|
||||
System.exit(2);
|
||||
}
|
||||
Map<String, String> o = new HashMap<>();
|
||||
for (int i = 1; i < args.length; i++) {
|
||||
String a = args[i];
|
||||
if (Set.of("--collision", "--no-markers", "--frame", "--bench", "--no-grid", "--no-fog", "--list")
|
||||
.contains(a)) {
|
||||
o.put(a, "1");
|
||||
} else if (i + 1 < args.length) {
|
||||
o.put(a, args[++i]);
|
||||
}
|
||||
}
|
||||
Path cd = Path.of(o.getOrDefault("--cd", System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd"));
|
||||
byte[] wad;
|
||||
if (args[0].equals("testworld")) {
|
||||
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
|
||||
wad = new TestWorld()
|
||||
.buildWad(
|
||||
base,
|
||||
rel -> {
|
||||
try {
|
||||
Path p = cd.resolve(rel);
|
||||
return Files.exists(p) ? Files.readAllBytes(p) : null;
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
},
|
||||
Map.of());
|
||||
} else {
|
||||
wad = Files.readAllBytes(Path.of(args[0]));
|
||||
}
|
||||
long t0 = System.nanoTime();
|
||||
LevelSceneAdapter.Result res = LevelSceneAdapter.fromWad(wad);
|
||||
System.out.printf(
|
||||
"scene: %d instances, %d markers, %d pages, converted in %.0f ms%n",
|
||||
res.scene().instances.size(),
|
||||
res.scene().markers.size(),
|
||||
res.scene().pages.size(),
|
||||
(System.nanoTime() - t0) / 1e6);
|
||||
if (o.containsKey("--list")) {
|
||||
for (int i = 0; i < res.stratNames().size(); i++) {
|
||||
var s = res.level().map().strats.get(i);
|
||||
System.out.printf(
|
||||
"o%d %s %.2f %.2f %.2f%n",
|
||||
i, res.stratNames().get(i), s.x() / 4096.0, s.y() / 4096.0, -s.z() / 4096.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> show(res, o));
|
||||
}
|
||||
|
||||
static int parseId(String s) {
|
||||
int k =
|
||||
switch (s.charAt(0)) {
|
||||
case 't' -> PickId.TILE;
|
||||
case 'o' -> PickId.OBJECT;
|
||||
case 'w' -> PickId.WAYPOINT;
|
||||
case 'z' -> PickId.ZONE;
|
||||
default -> throw new IllegalArgumentException("id " + s);
|
||||
};
|
||||
return PickId.of(k, Integer.parseInt(s.substring(1)));
|
||||
}
|
||||
|
||||
private static void show(LevelSceneAdapter.Result res, Map<String, String> o) {
|
||||
RenderScene scene = res.scene();
|
||||
JFrame f = new JFrame("Llama Level Viewer – " + res.level().wad().levelName());
|
||||
Viewport vp = new Viewport();
|
||||
String[] size = o.getOrDefault("--size", "1280x720").split("x");
|
||||
vp.setPreferredSize(new Dimension(Integer.parseInt(size[0]), Integer.parseInt(size[1])));
|
||||
vp.setScene(scene);
|
||||
vp.setGrid(0, new float[] {0, -res.gridH(), res.gridW(), 0});
|
||||
vp.setLayerVisible(LevelSceneAdapter.LAYER_COLLISION, o.containsKey("--collision"));
|
||||
if (o.containsKey("--no-markers")) {
|
||||
for (String l : List.of(
|
||||
LevelSceneAdapter.LAYER_MARKERS,
|
||||
LevelSceneAdapter.LAYER_WAYPOINTS,
|
||||
LevelSceneAdapter.LAYER_ZONES)) {
|
||||
vp.setLayerVisible(l, false);
|
||||
}
|
||||
}
|
||||
if (o.containsKey("--hide")) {
|
||||
for (String l : o.get("--hide").split(",")) {
|
||||
vp.setLayerVisible(l, false);
|
||||
}
|
||||
}
|
||||
vp.setGridVisible(!o.containsKey("--no-grid"));
|
||||
vp.setFog(!o.containsKey("--no-fog"));
|
||||
|
||||
JLabel status = new JLabel(" ");
|
||||
JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
|
||||
for (String l : List.of(
|
||||
LevelSceneAdapter.LAYER_TILES,
|
||||
LevelSceneAdapter.LAYER_OBJECTS,
|
||||
LevelSceneAdapter.LAYER_MARKERS,
|
||||
LevelSceneAdapter.LAYER_WAYPOINTS,
|
||||
LevelSceneAdapter.LAYER_ZONES,
|
||||
LevelSceneAdapter.LAYER_COLLISION)) {
|
||||
JCheckBox cb = new JCheckBox(l, vp.isLayerVisible(l));
|
||||
cb.setFocusable(false);
|
||||
cb.addActionListener(e -> vp.setLayerVisible(l, cb.isSelected()));
|
||||
bar.add(cb);
|
||||
}
|
||||
JComboBox<Camera.View> views = new JComboBox<>(Camera.View.values());
|
||||
views.setFocusable(false);
|
||||
views.addActionListener(e -> vp.setView((Camera.View) views.getSelectedItem()));
|
||||
bar.add(views);
|
||||
JComboBox<Gizmo.Mode> modes = new JComboBox<>(Gizmo.Mode.values());
|
||||
modes.setFocusable(false);
|
||||
modes.addActionListener(e -> {
|
||||
vp.gizmo().setMode((Gizmo.Mode) modes.getSelectedItem());
|
||||
vp.requestRender();
|
||||
});
|
||||
bar.add(modes);
|
||||
JCheckBox snap = new JCheckBox("snap", false);
|
||||
snap.setFocusable(false);
|
||||
snap.addActionListener(e -> vp.gizmo().setSnap(new Gizmo.Snap(snap.isSelected(), 0.25f, 15f, 0.1f)));
|
||||
bar.add(snap);
|
||||
|
||||
vp.addViewportListener(new ViewportListener() {
|
||||
@Override
|
||||
public void picked(PickEvent e) {
|
||||
if (e.button() != MouseEvent.BUTTON1) {
|
||||
return;
|
||||
}
|
||||
select(vp, scene, e.id());
|
||||
status.setText(describe(res, e.id())
|
||||
+ (e.world() != null
|
||||
? String.format(" @ %.2f %.2f %.2f", e.world().x, e.world().y, e.world().z)
|
||||
: ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hovered(int id) {
|
||||
if (id != 0) {
|
||||
status.setText("hover: " + describe(res, id));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void gizmo(GizmoEvent e) {
|
||||
// demo: apply the delta directly (the editor turns END into an undoable command)
|
||||
Matrix4f m = e.phase() == GizmoEvent.Phase.CANCEL ? e.start() : e.result();
|
||||
for (int id : vp.selection()) {
|
||||
RenderInstance ri = scene.instance(id);
|
||||
if (ri != null) {
|
||||
ri.transform.set(m);
|
||||
scene.changed(ri);
|
||||
}
|
||||
Marker mk = scene.marker(id);
|
||||
if (mk != null) {
|
||||
Vector3f t = m.getTranslation(new Vector3f());
|
||||
mk.x = t.x;
|
||||
mk.y = t.y;
|
||||
mk.z = t.z;
|
||||
scene.changed();
|
||||
}
|
||||
}
|
||||
if (e.phase() != GizmoEvent.Phase.START) {
|
||||
vp.gizmo().setTarget(m);
|
||||
}
|
||||
vp.requestRender();
|
||||
}
|
||||
});
|
||||
|
||||
f.getContentPane().add(bar, BorderLayout.NORTH);
|
||||
f.getContentPane().add(vp, BorderLayout.CENTER);
|
||||
f.getContentPane().add(status, BorderLayout.SOUTH);
|
||||
f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
|
||||
f.pack();
|
||||
f.setVisible(true);
|
||||
|
||||
Gizmo.Mode gm =
|
||||
o.containsKey("--gizmo") ? Gizmo.Mode.valueOf(o.get("--gizmo").toUpperCase()) : null;
|
||||
Timer start = new Timer(600, e -> {
|
||||
if (gm != null) {
|
||||
vp.gizmo().setMode(gm);
|
||||
}
|
||||
List<Integer> sel = new ArrayList<>();
|
||||
if (o.containsKey("--select")) {
|
||||
for (String s : o.get("--select").split(",")) {
|
||||
sel.add(parseId(s));
|
||||
}
|
||||
select(vp, scene, sel.get(0));
|
||||
vp.setSelection(sel);
|
||||
}
|
||||
vp.frameAll();
|
||||
if (o.containsKey("--view")) {
|
||||
vp.setView(Camera.View.valueOf(o.get("--view").toUpperCase()));
|
||||
vp.frameAll();
|
||||
}
|
||||
if (o.containsKey("--frame") && !sel.isEmpty()) {
|
||||
vp.frameSelection();
|
||||
}
|
||||
if (o.containsKey("--cam")) {
|
||||
vp.stopAnimation();
|
||||
String[] c = o.get("--cam").split(",");
|
||||
Camera cam = vp.camera();
|
||||
cam.position.set(Float.parseFloat(c[0]), Float.parseFloat(c[1]), Float.parseFloat(c[2]));
|
||||
cam.yaw = (float) Math.toRadians(Double.parseDouble(c[3]));
|
||||
cam.pitch = (float) Math.toRadians(Double.parseDouble(c[4]));
|
||||
if (c.length > 5) {
|
||||
cam.distance = Float.parseFloat(c[5]);
|
||||
}
|
||||
}
|
||||
if (o.containsKey("--fov")) {
|
||||
vp.camera().fovY = (float) Math.toRadians(Double.parseDouble(o.get("--fov")));
|
||||
}
|
||||
System.out.println("GL: " + vp.glInfo() + (vp.glError() != null ? " ERROR " + vp.glError() : ""));
|
||||
if (o.containsKey("--bench")) {
|
||||
bench(vp);
|
||||
if (!o.containsKey("--shot")) {
|
||||
System.exit(0);
|
||||
}
|
||||
}
|
||||
if (o.containsKey("--shot")) {
|
||||
// let the camera animation finish
|
||||
Timer t = new Timer(400, ev -> {
|
||||
try {
|
||||
vp.renderNow();
|
||||
File out = new File(o.get("--shot"));
|
||||
ImageIO.write(vp.screenshot(), out.getName().endsWith(".jpg") ? "jpg" : "png", out);
|
||||
Viewport.GlRendererStats st = vp.stats();
|
||||
System.out.printf(
|
||||
"shot %s: %d draw calls, %d triangles, chunks %d/%d, cpu %.1f ms%n",
|
||||
out, st.drawCalls(), st.triangles(), st.chunksDrawn(), st.chunksTotal(), st.cpuMs());
|
||||
} catch (IOException ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
System.exit(0);
|
||||
});
|
||||
t.setRepeats(false);
|
||||
t.start();
|
||||
}
|
||||
});
|
||||
start.setRepeats(false);
|
||||
start.start();
|
||||
}
|
||||
|
||||
private static void select(Viewport vp, RenderScene scene, int id) {
|
||||
vp.setSelection(id == 0 ? List.of() : List.of(id));
|
||||
RenderInstance ri = scene.instance(id);
|
||||
Marker mk = scene.marker(id);
|
||||
Matrix4f m = null;
|
||||
if (ri != null) {
|
||||
m = new Matrix4f(ri.transform);
|
||||
} else if (mk != null) {
|
||||
m = new Matrix4f().translation(mk.x, mk.y, mk.z);
|
||||
}
|
||||
vp.gizmo().setVisible(m != null);
|
||||
if (m != null) {
|
||||
vp.gizmo().setTarget(m);
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(LevelSceneAdapter.Result res, int id) {
|
||||
int k = PickId.kind(id), i = PickId.index(id);
|
||||
if (k == PickId.OBJECT && i < res.stratNames().size()) {
|
||||
return "object " + i + " " + res.stratNames().get(i);
|
||||
}
|
||||
if (k == PickId.TILE && i < res.level().map().tiles.size()) {
|
||||
return "tile " + i + " (model " + res.level().map().tiles.get(i).model() + ")";
|
||||
}
|
||||
return PickId.toString(id);
|
||||
}
|
||||
|
||||
/** Orbits once around the level and prints frame statistics. */
|
||||
private static void bench(Viewport vp) {
|
||||
Camera c = vp.camera();
|
||||
Vector3f piv = c.pivot();
|
||||
int frames = 120;
|
||||
double total = 0, worst = 0, cpu = 0;
|
||||
Viewport.GlRendererStats st = null;
|
||||
for (int i = 0; i < frames; i++) {
|
||||
c.yaw += (float) (Math.PI * 2 / frames);
|
||||
c.position.set(c.forward().mul(-c.distance).add(piv));
|
||||
long t0 = System.nanoTime();
|
||||
vp.renderNow();
|
||||
double ms = (System.nanoTime() - t0) / 1e6;
|
||||
total += ms;
|
||||
worst = Math.max(worst, ms);
|
||||
st = vp.stats();
|
||||
cpu += st.cpuMs();
|
||||
}
|
||||
System.out.printf(
|
||||
"bench: %d frames, avg %.1f ms (%.0f fps), CPU submit %.1f ms, worst %.1f ms, last: %d draw calls, %d triangles, chunks %d/%d%n",
|
||||
frames,
|
||||
total / frames,
|
||||
1000 / (total / frames),
|
||||
cpu / frames,
|
||||
worst,
|
||||
st.drawCalls(),
|
||||
st.triangles(),
|
||||
st.chunksDrawn(),
|
||||
st.chunksTotal());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.MouseEvent;
|
||||
|
||||
/**
|
||||
* Input hook for editor tools ({@link Viewport#setInput}). Gets left-button presses that do not hit
|
||||
* the gizmo (and are not Alt-orbit), the following drag/release, every mouse move and key events
|
||||
* before the viewport's own handling. Returning true consumes the event. Swing thread.
|
||||
*/
|
||||
public interface ViewportInput {
|
||||
/** Left button pressed; true = this tool owns the following drag and release (no pick event). */
|
||||
default boolean mousePressed(MouseEvent e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default void mouseDragged(MouseEvent e) {}
|
||||
|
||||
default void mouseReleased(MouseEvent e) {}
|
||||
|
||||
/** Mouse moved without a button (also while the gizmo is hovered). */
|
||||
default void mouseMoved(MouseEvent e) {}
|
||||
|
||||
default void mouseExited(MouseEvent e) {}
|
||||
|
||||
default boolean keyPressed(KeyEvent e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean keyReleased(KeyEvent e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eliasmueller.llamamod.editor.render;
|
||||
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Viewport callbacks (Swing thread). All methods have empty defaults. */
|
||||
public interface ViewportListener {
|
||||
/**
|
||||
* Click without drag (left button unless noted).
|
||||
*
|
||||
* @param id picked id or 0
|
||||
* @param world hit point on geometry (or marker), {@code null} if nothing was hit
|
||||
* @param button {@link java.awt.event.MouseEvent} button
|
||||
* @param modifiers {@link java.awt.event.InputEvent} extended modifiers (shift/ctrl for multi-select)
|
||||
*/
|
||||
record PickEvent(int id, Vector3f world, int x, int y, int button, int modifiers, int clickCount) {}
|
||||
|
||||
default void picked(PickEvent e) {}
|
||||
|
||||
/** Id under the mouse changed (0 = none). */
|
||||
default void hovered(int id) {}
|
||||
|
||||
default void gizmo(GizmoEvent e) {}
|
||||
|
||||
default void cameraChanged(Camera c) {}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.SceneEvent;
|
||||
import com.eliasmueller.llamamod.editor.ui.AssetDrop;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.ListCellRenderer;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.UIManager;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Asset browser tab "All worlds": every object type of every level on the CD with thumbnails,
|
||||
* search and world/category filters. Drag a type into the viewport to import and place it,
|
||||
* double-click places it at the view centre.
|
||||
*/
|
||||
final class AllWorldsTab extends JPanel {
|
||||
static final String DRAG_KIND = "xobj";
|
||||
private static final int THUMB = 84;
|
||||
|
||||
private final EditorContext ctx;
|
||||
private final DefaultListModel<ObjectCatalog.Entry> model = new DefaultListModel<>();
|
||||
private final JList<ObjectCatalog.Entry> list = new JList<>(model);
|
||||
private final JTextField search = new JTextField(14);
|
||||
private final JComboBox<String> world = new JComboBox<>();
|
||||
private final JComboBox<String> category = new JComboBox<>();
|
||||
private final JCheckBox importable = new JCheckBox("Importable only", true);
|
||||
private final JLabel count = new JLabel();
|
||||
private final JLabel state = new JLabel(" ", SwingConstants.CENTER);
|
||||
private final Map<String, Icon> thumbs = new HashMap<>();
|
||||
|
||||
AllWorldsTab(EditorContext ctx) {
|
||||
super(new BorderLayout());
|
||||
this.ctx = ctx;
|
||||
world.addItem("All worlds");
|
||||
for (int w = 1; w <= 8; w++) {
|
||||
world.addItem(Worlds.name(w));
|
||||
}
|
||||
world.addItem("Other levels");
|
||||
category.addItem("All categories");
|
||||
for (String c : ObjectCatalog.CATEGORIES) {
|
||||
category.addItem(ObjectCatalog.categoryLabel(c));
|
||||
}
|
||||
search.putClientProperty("JTextField.placeholderText", "Search all worlds…");
|
||||
search.putClientProperty("JTextField.leadingIcon", Icons.get("search", 13));
|
||||
search.putClientProperty("JTextField.showClearButton", true);
|
||||
SwingListeners.onTextChange(search, () -> apply());
|
||||
world.addActionListener(e -> apply());
|
||||
category.addActionListener(e -> apply());
|
||||
importable.addActionListener(e -> apply());
|
||||
JButton bulk = new JButton("Import world…", Icons.get("import", 14));
|
||||
bulk.setToolTipText("Import many object types of one world or level at once (with a size preview)");
|
||||
bulk.addActionListener(e -> ImportWorldDialog.show(ctx, world.getSelectedIndex()));
|
||||
JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 3));
|
||||
top.add(search);
|
||||
top.add(world);
|
||||
top.add(category);
|
||||
top.add(importable);
|
||||
top.add(bulk);
|
||||
top.add(count);
|
||||
add(top, BorderLayout.NORTH);
|
||||
|
||||
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
|
||||
list.setVisibleRowCount(-1);
|
||||
list.setFixedCellWidth(THUMB + 46);
|
||||
list.setFixedCellHeight(THUMB + 40);
|
||||
list.setCellRenderer(new Cell());
|
||||
javax.swing.ToolTipManager.sharedInstance().registerComponent(list);
|
||||
AssetDrop.dragSource(list, e -> AssetDrop.payload(DRAG_KIND, e.key()));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent e) {
|
||||
ObjectCatalog.Entry it = list.getSelectedValue();
|
||||
if (it != null && e.getClickCount() == 2 && e.getButton() == MouseEvent.BUTTON1) {
|
||||
placeAtCentre(it);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
popup(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
popup(e);
|
||||
}
|
||||
});
|
||||
JPanel centre = new JPanel(new BorderLayout());
|
||||
centre.add(new JScrollPane(list), BorderLayout.CENTER);
|
||||
state.setForeground(UIManager.getColor("Label.disabledForeground"));
|
||||
centre.add(state, BorderLayout.SOUTH);
|
||||
add(centre, BorderLayout.CENTER);
|
||||
CatalogStore.addListener(this::reload);
|
||||
ctx.addDocumentListener(d -> list.repaint());
|
||||
ctx.addSceneListener(e -> {
|
||||
if (e.type() == SceneEvent.Type.RELOADED) {
|
||||
list.repaint();
|
||||
}
|
||||
});
|
||||
reload();
|
||||
}
|
||||
|
||||
private void popup(MouseEvent e) {
|
||||
if (!e.isPopupTrigger()) {
|
||||
return;
|
||||
}
|
||||
int i = list.locationToIndex(e.getPoint());
|
||||
if (i < 0) {
|
||||
return;
|
||||
}
|
||||
list.setSelectedIndex(i);
|
||||
ObjectCatalog.Entry it = model.get(i);
|
||||
JPopupMenu m = new JPopupMenu();
|
||||
JMenuItem place = new JMenuItem("Place at view centre");
|
||||
place.addActionListener(a -> placeAtCentre(it));
|
||||
place.setEnabled(ctx.document() != null && it.importable());
|
||||
JMenuItem imp = new JMenuItem("Import into the project");
|
||||
imp.addActionListener(a -> Importer.importTypes(ctx, List.of(it), o -> list.repaint()));
|
||||
imp.setEnabled(ctx.document() != null && it.importable());
|
||||
JMenuItem lvl = new JMenuItem("Import all of " + it.levelName() + "…");
|
||||
lvl.addActionListener(a -> ImportWorldDialog.show(ctx, it.level()));
|
||||
lvl.setEnabled(ctx.document() != null);
|
||||
m.add(place);
|
||||
m.add(imp);
|
||||
m.addSeparator();
|
||||
m.add(lvl);
|
||||
m.show(list, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private void placeAtCentre(ObjectCatalog.Entry it) {
|
||||
if (ctx.document() == null) {
|
||||
return;
|
||||
}
|
||||
Vector3f p = ctx.viewCentre();
|
||||
Importer.importAndPlace(ctx, it, p);
|
||||
}
|
||||
|
||||
void reload() {
|
||||
ObjectCatalog c = CatalogStore.get();
|
||||
if (c == null) {
|
||||
model.clear();
|
||||
state.setText(CatalogStore.building() ? "Building the catalogue of all worlds…" : "");
|
||||
if (!CatalogStore.building()) {
|
||||
CatalogStore.ensure(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.setText(c.entries().size() + " object types from "
|
||||
+ c.entries().stream()
|
||||
.map(ObjectCatalog.Entry::level)
|
||||
.distinct()
|
||||
.count() + " levels · drag into the viewport to import and place");
|
||||
apply();
|
||||
}
|
||||
|
||||
private void apply() {
|
||||
ObjectCatalog c = CatalogStore.get();
|
||||
model.clear();
|
||||
if (c == null) {
|
||||
return;
|
||||
}
|
||||
int w = world.getSelectedIndex();
|
||||
int wf = w == 0 ? 0 : w <= 8 ? w : -1;
|
||||
String cat =
|
||||
category.getSelectedIndex() <= 0 ? null : ObjectCatalog.CATEGORIES[category.getSelectedIndex() - 1];
|
||||
List<ObjectCatalog.Entry> l = c.filter(search.getText(), Math.max(wf, 0), cat, importable.isSelected());
|
||||
int n = 0;
|
||||
for (ObjectCatalog.Entry e : l) {
|
||||
if (wf < 0 && e.world() >= 1 && e.world() <= 8) {
|
||||
continue;
|
||||
}
|
||||
model.addElement(e);
|
||||
n++;
|
||||
}
|
||||
count.setText(n + " of " + c.entries().size());
|
||||
}
|
||||
|
||||
private Icon thumb(ObjectCatalog.Entry e) {
|
||||
return thumbs.computeIfAbsent(e.key(), k -> {
|
||||
ObjectCatalog c = CatalogStore.get();
|
||||
Path f = c == null ? null : c.thumbFile(e);
|
||||
try {
|
||||
if (f != null && Files.isRegularFile(f)) {
|
||||
BufferedImage img = ImageIO.read(f.toFile());
|
||||
if (img != null) {
|
||||
BufferedImage s = new BufferedImage(THUMB, THUMB, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = s.createGraphics();
|
||||
g.setRenderingHint(
|
||||
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
if (!e.importable()) {
|
||||
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.35f));
|
||||
}
|
||||
g.drawImage(img, 0, 0, THUMB, THUMB, null);
|
||||
g.dispose();
|
||||
return new ImageIcon(s);
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ex) {
|
||||
// fall back to the category icon
|
||||
}
|
||||
return NodeIcons.category(categoryForIcon(e.category()), 40);
|
||||
});
|
||||
}
|
||||
|
||||
static String categoryForIcon(String c) {
|
||||
return switch (c) {
|
||||
case "enemy", "mechanism", "character", "creature", "effect" -> "other";
|
||||
default -> c;
|
||||
};
|
||||
}
|
||||
|
||||
static String tooltip(ObjectCatalog.Entry e) {
|
||||
StringBuilder sb = new StringBuilder("<html><b>")
|
||||
.append(e.type())
|
||||
.append("</b><br>")
|
||||
.append(e.levelName())
|
||||
.append(" (")
|
||||
.append(e.level())
|
||||
.append(")<br>")
|
||||
.append(ObjectCatalog.categoryLabel(e.category()))
|
||||
.append(" · ")
|
||||
.append(e.instances())
|
||||
.append(e.instances() == 1 ? " instance" : " instances")
|
||||
.append(" · ")
|
||||
.append(e.params())
|
||||
.append(" parameters<br>")
|
||||
.append(String.format(
|
||||
Locale.ROOT,
|
||||
"needs %d code units, %d models, %d animations, %d sounds, %d textures (%.0f KB)",
|
||||
e.units(),
|
||||
e.models(),
|
||||
e.anims(),
|
||||
e.sounds(),
|
||||
e.textures(),
|
||||
e.bytes() / 1024.0));
|
||||
for (String r : e.reasons()) {
|
||||
sb.append("<br><font color='#d93f3f'>✗ ").append(r).append("</font>");
|
||||
}
|
||||
for (String r : e.notes()) {
|
||||
sb.append("<br>• ").append(r);
|
||||
}
|
||||
return sb.append("</html>").toString();
|
||||
}
|
||||
|
||||
private final class Cell extends JPanel implements ListCellRenderer<ObjectCatalog.Entry> {
|
||||
private final JLabel img = new JLabel();
|
||||
private final JLabel title = new JLabel();
|
||||
private final JLabel sub = new JLabel();
|
||||
|
||||
Cell() {
|
||||
super(new BorderLayout());
|
||||
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
|
||||
img.setHorizontalAlignment(SwingConstants.CENTER);
|
||||
img.setPreferredSize(new Dimension(THUMB, THUMB));
|
||||
add(img, BorderLayout.CENTER);
|
||||
JPanel t = new JPanel(new BorderLayout());
|
||||
t.setOpaque(false);
|
||||
title.setHorizontalAlignment(SwingConstants.CENTER);
|
||||
title.setFont(title.getFont().deriveFont(Font.BOLD, 11f));
|
||||
sub.setHorizontalAlignment(SwingConstants.CENTER);
|
||||
sub.setFont(sub.getFont().deriveFont(10f));
|
||||
t.add(title, BorderLayout.NORTH);
|
||||
t.add(sub, BorderLayout.SOUTH);
|
||||
add(t, BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(
|
||||
JList<? extends ObjectCatalog.Entry> l, ObjectCatalog.Entry e, int index, boolean sel, boolean focus) {
|
||||
img.setIcon(thumb(e));
|
||||
boolean done = Importer.importedCode(ctx, Importer.ref(e)) >= 0;
|
||||
title.setText((done ? "✓ " : "") + e.type());
|
||||
String w = e.world() >= 1 ? Worlds.name(e.world()) + " " + e.chapter() : e.level();
|
||||
sub.setText(w + " · " + (e.bytes() < 1024 ? e.bytes() + " B" : e.bytes() / 1024 + " KB"));
|
||||
Color fg = sel ? UIManager.getColor("List.selectionForeground") : UIManager.getColor("List.foreground");
|
||||
title.setForeground(
|
||||
!e.importable() ? UIManager.getColor("Label.disabledForeground") : done ? Icons.GREEN : fg);
|
||||
sub.setForeground(sel ? fg : UIManager.getColor("Label.disabledForeground"));
|
||||
setBackground(sel ? UIManager.getColor("List.selectionBackground") : l.getBackground());
|
||||
setToolTipText(tooltip(e));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.AssetDrop;
|
||||
import com.eliasmueller.llamamod.editor.ui.AssetTab;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorExtensions;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorPlugin;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.Problem;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
import java.awt.BorderLayout;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
|
||||
/**
|
||||
* Cross-level object import: asset tab "All worlds" (catalogue of every object type on the CD),
|
||||
* drag & drop import + placement, "Import world…" bulk import, inspector note "imported from",
|
||||
* limit checks. Merge logic: {@link CrossImport}, format: {@code format.stpcmerge}.
|
||||
*/
|
||||
public final class CatalogEditorPlugin implements EditorPlugin {
|
||||
@Override
|
||||
public void install(EditorExtensions x) {
|
||||
CrossImport.init();
|
||||
x.assetTab(new AssetTab("allworlds", "All worlds", "world", 15, AllWorldsTab::new));
|
||||
AssetDrop.register(AllWorldsTab.DRAG_KIND, (ctx, data, ground) -> {
|
||||
ObjectCatalog.Entry e = CatalogStore.find(data);
|
||||
if (e == null) {
|
||||
ctx.status("Unknown object type " + data);
|
||||
return;
|
||||
}
|
||||
Importer.importAndPlace(ctx, e, ground);
|
||||
});
|
||||
x.action(ActionSpec.of(
|
||||
"xobj.importWorld",
|
||||
"Level",
|
||||
40,
|
||||
10,
|
||||
"Import objects of a world…",
|
||||
"import",
|
||||
"ctrl shift I",
|
||||
c -> ImportWorldDialog.show(c, 0))
|
||||
.enabledWhen(c -> c.document() != null)
|
||||
.tip("Copy object types (enemies, pickups, platforms …) of another world into this level"));
|
||||
x.action(ActionSpec.of("xobj.rebuildCatalog", "Level", 40, 20, "Rebuild object catalogue", "world", null, c -> {
|
||||
CatalogStore.rebuild(c);
|
||||
})
|
||||
.tip("Scan all levels of the CD again (thumbnails, sizes)"));
|
||||
x.inspector(new ImportedSection());
|
||||
x.check((scene, out) -> {
|
||||
if (CrossImport.codes(scene).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
CrossImport.Limits l = CrossImport.limits(scene);
|
||||
for (String p : l.problems()) {
|
||||
out.add(Problem.error("Imported objects: " + p, null));
|
||||
}
|
||||
for (String w : l.warnings()) {
|
||||
out.add(Problem.warning("Imported objects: " + w, null));
|
||||
}
|
||||
});
|
||||
x.onOpen(CatalogStore::ensure);
|
||||
}
|
||||
|
||||
/** Inspector: where an imported object type comes from. */
|
||||
private static final class ImportedSection implements InspectorSection {
|
||||
@Override
|
||||
public String title() {
|
||||
return "Imported object";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int order() {
|
||||
return 20;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean appliesTo(List<Node> sel) {
|
||||
return sel.size() == 1
|
||||
&& sel.get(0) instanceof Strat s
|
||||
&& s.scene() != null
|
||||
&& CrossImport.codes(s.scene()).containsKey(s.code());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JComponent create(EditorContext ctx, List<Node> sel) {
|
||||
Strat s = (Strat) sel.get(0);
|
||||
CrossImport.TypeRef t = CrossImport.codes(s.scene()).get(s.code());
|
||||
ObjectCatalog.Entry e = CatalogStore.find(t.level() + ":" + t.type() + "@" + Integer.toHexString(t.code()));
|
||||
String where = e != null ? e.levelName() : levelLabel(t.level());
|
||||
JLabel l = new JLabel(
|
||||
"<html>Imported from <b>" + where + "</b> (" + t.level() + ")"
|
||||
+ (e == null
|
||||
? ""
|
||||
: String.format(
|
||||
Locale.ROOT,
|
||||
"<br>%s · %d models, %d animations, %d sounds, %.0f KB",
|
||||
ObjectCatalog.categoryLabel(e.category()),
|
||||
e.models(),
|
||||
e.anims(),
|
||||
e.sounds(),
|
||||
e.bytes() / 1024.0))
|
||||
+ "<br><font color='gray'>Code, models, textures and sounds are copied into this level when it is built.</font></html>",
|
||||
Icons.get("import", 16),
|
||||
JLabel.LEADING);
|
||||
l.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||
JPanel p = new JPanel(new BorderLayout());
|
||||
p.add(l, BorderLayout.CENTER);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
static String levelLabel(String level) {
|
||||
java.util.regex.Matcher m =
|
||||
java.util.regex.Pattern.compile("t1l(\\d)m0*(\\d+)").matcher(level);
|
||||
return m.matches() ? Worlds.name(Integer.parseInt(m.group(1))) + " – Chapter " + m.group(2) : level;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/** The global object catalogue of this session: loaded from the cache or built once in the background. */
|
||||
final class CatalogStore {
|
||||
private CatalogStore() {}
|
||||
|
||||
private static volatile ObjectCatalog catalog;
|
||||
private static volatile boolean building;
|
||||
private static final List<Runnable> LISTENERS = new CopyOnWriteArrayList<>();
|
||||
|
||||
static ObjectCatalog get() {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
static boolean building() {
|
||||
return building;
|
||||
}
|
||||
|
||||
static void addListener(Runnable r) {
|
||||
LISTENERS.add(r);
|
||||
}
|
||||
|
||||
static Path dir(EditorContext ctx) {
|
||||
return ctx.env().editorDir().resolve("catalog");
|
||||
}
|
||||
|
||||
/** Loads or builds the catalogue (needs the CD); listeners run on the Swing thread when it is ready. */
|
||||
static void ensure(EditorContext ctx) {
|
||||
if (catalog != null || building || !ctx.env().hasCd()) {
|
||||
return;
|
||||
}
|
||||
Path cd = ctx.env().cd();
|
||||
Path dir = dir(ctx);
|
||||
building = true;
|
||||
Thread t = new Thread(
|
||||
() -> {
|
||||
ObjectCatalog c = ObjectCatalog.cached(dir, cd);
|
||||
try {
|
||||
if (c == null) {
|
||||
c = ObjectCatalog.build(
|
||||
cd,
|
||||
dir,
|
||||
(a, b) -> SwingUtilities.invokeLater(
|
||||
() -> ctx.status("Building the object catalogue of all worlds… " + a + "/"
|
||||
+ b + " levels")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
SwingUtilities.invokeLater(() -> ctx.error("Object catalogue", e));
|
||||
}
|
||||
ObjectCatalog done = c;
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
catalog = done;
|
||||
building = false;
|
||||
if (done != null) {
|
||||
ctx.status("Object catalogue: " + done.entries().size() + " types of all worlds");
|
||||
}
|
||||
LISTENERS.forEach(Runnable::run);
|
||||
});
|
||||
},
|
||||
"object-catalogue");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
/** Drops the cache and builds the catalogue again. */
|
||||
static void rebuild(EditorContext ctx) {
|
||||
if (building) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
java.nio.file.Files.deleteIfExists(dir(ctx).resolve("catalog.json"));
|
||||
} catch (java.io.IOException e) {
|
||||
// rebuilt anyway
|
||||
}
|
||||
catalog = null;
|
||||
LISTENERS.forEach(Runnable::run);
|
||||
ensure(ctx);
|
||||
}
|
||||
|
||||
/** Entry by key (level:type@code), or null. */
|
||||
static ObjectCatalog.Entry find(String key) {
|
||||
ObjectCatalog c = catalog;
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
for (ObjectCatalog.Entry e : c.entries()) {
|
||||
if (e.key().equals(key)) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.io.Bases;
|
||||
import com.eliasmueller.llamamod.editor.io.Project;
|
||||
import com.eliasmueller.llamamod.editor.io.ProjectIO;
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.PropType;
|
||||
import com.eliasmueller.llamamod.editor.model.RawChunk;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.StratCatalog;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureAnim;
|
||||
import com.eliasmueller.llamamod.editor.model.TextureEntry;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
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.TextMerge;
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Object types imported from other levels into a project (notes/editor-cross-import.md). The
|
||||
* project stores only the list of imports (level.json key {@value #KEY}: batches of {level, type,
|
||||
* code}); on load every batch is merged, in order, into the reference and the current scene alike
|
||||
* (STPC code/models/animations, TEXT pages/entries, SMPC samples, object catalogue, strat code
|
||||
* offsets), so the stored diff keeps describing only the user's edits and builds are deterministic.
|
||||
*/
|
||||
public final class CrossImport {
|
||||
public static final String KEY = "importedTypes";
|
||||
|
||||
private CrossImport() {}
|
||||
|
||||
static {
|
||||
ProjectIO.addLoadHook(CrossImport::onLoad);
|
||||
}
|
||||
|
||||
/** Makes sure the load hook is registered. */
|
||||
public static void init() {}
|
||||
|
||||
/** One imported type: source level, type name and its code entry in the source STPC. */
|
||||
public record TypeRef(String level, String type, int code) {
|
||||
Map<String, Object> json() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("level", level);
|
||||
m.put("type", type);
|
||||
m.put("code", (long) code);
|
||||
return m;
|
||||
}
|
||||
|
||||
static TypeRef of(Object o) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> m = (Map<String, Object>) o;
|
||||
return new TypeRef(
|
||||
((String) m.get("level")).toLowerCase(Locale.ROOT),
|
||||
(String) m.getOrDefault("type", ""),
|
||||
((Number) m.get("code")).intValue());
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of merging one batch into a scene. */
|
||||
public record Outcome(
|
||||
byte[] stpc,
|
||||
byte[] smpc,
|
||||
byte[] lgpc,
|
||||
byte[] srpc,
|
||||
StpcMerge.OffsetMap target,
|
||||
List<TextChunk.Page> pages,
|
||||
List<TextChunk.TextureEntry> entries,
|
||||
List<TextMerge.Anim> anims,
|
||||
Map<TypeRef, Integer> codes,
|
||||
Map<TypeRef, StratCatalog.Variant> variants,
|
||||
Map<Integer, String> names,
|
||||
StpcMerge.Stats stats,
|
||||
List<String> warnings) {}
|
||||
|
||||
// ------------------------------------------------------------------ project records
|
||||
|
||||
/** The import batches stored in a project (in order). */
|
||||
public static List<List<TypeRef>> records(Map<String, Object> extra) {
|
||||
List<List<TypeRef>> out = new ArrayList<>();
|
||||
Object v = extra.get(KEY);
|
||||
if (v instanceof List<?> l) {
|
||||
for (Object b : l) {
|
||||
if (b instanceof Map<?, ?> m && m.get("types") instanceof List<?> ts) {
|
||||
List<TypeRef> batch = new ArrayList<>();
|
||||
for (Object t : ts) {
|
||||
batch.add(TypeRef.of(t));
|
||||
}
|
||||
out.add(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** All imported types of a project. */
|
||||
public static List<TypeRef> imported(Project p) {
|
||||
List<TypeRef> out = new ArrayList<>();
|
||||
records(p.extra()).forEach(out::addAll);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Imported entries of a scene by their current code offset. */
|
||||
public static Map<Integer, TypeRef> codes(Scene s) {
|
||||
Map<Integer, TypeRef> m = CODES.get(s);
|
||||
return m == null ? Map.of() : Map.copyOf(m);
|
||||
}
|
||||
|
||||
/** Current code offsets of the imported entries per scene (rebuilt on every merge). */
|
||||
private static final Map<Scene, Map<Integer, TypeRef>> CODES =
|
||||
java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>());
|
||||
|
||||
private static void onLoad(Map<String, Object> json, Bases cd, Scene ref, Scene cur, List<String> warnings)
|
||||
throws IOException {
|
||||
List<List<TypeRef>> recs = records(json);
|
||||
for (List<TypeRef> batch : recs) {
|
||||
try {
|
||||
Outcome o = compute(ref, batch, cd);
|
||||
apply(o, ref, false, -1, -1, -1);
|
||||
apply(o, cur, false, -1, -1, -1);
|
||||
warnings.addAll(o.warnings);
|
||||
} catch (RuntimeException e) {
|
||||
warnings.add("imported object types " + names(batch) + " could not be merged: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String names(List<TypeRef> batch) {
|
||||
List<String> n = new ArrayList<>();
|
||||
for (TypeRef t : batch) {
|
||||
n.add(t.type + " (" + t.level + ")");
|
||||
}
|
||||
return n.size() > 4 ? n.subList(0, 4) + " +" + (n.size() - 4) : n.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a batch into an open project: merges into its reference and its scene (events fire on
|
||||
* the scene) and records the batch. Strat code offsets shift, so the caller should clear the
|
||||
* undo history. Types already imported or present in the base with the same code are skipped.
|
||||
*/
|
||||
public static Outcome importInto(Project p, List<TypeRef> batch, Bases cd) throws IOException {
|
||||
Outcome o = compute(p.reference(), batch, cd);
|
||||
commit(p, batch, o);
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second half of {@link #importInto}: writes an outcome computed from the project's reference
|
||||
* (possibly on another thread) into reference and scene and records the batch. Swing thread.
|
||||
*/
|
||||
public static void commit(Project p, List<TypeRef> batch, Outcome o) {
|
||||
Scene ref = p.reference(), cur = p.scene();
|
||||
int pages = ref.pages().size(),
|
||||
tex = ref.textures().size(),
|
||||
anims = ref.textureAnims().size();
|
||||
apply(o, ref, false, -1, -1, -1);
|
||||
apply(o, cur, true, pages, tex, anims);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> l = p.extra().get(KEY) instanceof List<?> x ? (List<Object>) x : new ArrayList<>();
|
||||
List<Object> nl = new ArrayList<>(l);
|
||||
Map<String, Object> rec = new LinkedHashMap<>();
|
||||
List<Object> ts = new ArrayList<>();
|
||||
for (TypeRef t : batch) {
|
||||
ts.add(t.json());
|
||||
}
|
||||
rec.put("types", ts);
|
||||
nl.add(rec);
|
||||
p.extra().put(KEY, nl);
|
||||
}
|
||||
|
||||
/** Engine limits of a level (notes/editor-cross-import.md). */
|
||||
public record Limits(int stpcBytes, int pages, int textures, int samples, int speech, int texts, long heapBytes) {
|
||||
public static final long HEAP = 0x1000000;
|
||||
|
||||
public List<String> problems() {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (samples > SoundMerge.MAX_SAMPLES) {
|
||||
out.add("too many sound samples: " + samples + " (max " + SoundMerge.MAX_SAMPLES + ")");
|
||||
}
|
||||
if (pages > 127) {
|
||||
out.add("too many texture pages: " + pages + " (max 127)");
|
||||
}
|
||||
if (heapBytes > HEAP) {
|
||||
out.add(String.format(
|
||||
Locale.ROOT,
|
||||
"level data ≈ %.1f MB exceeds the game's 16 MB level memory",
|
||||
heapBytes / 1048576.0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<String> warnings() {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (heapBytes > HEAP * 3 / 4 && heapBytes <= HEAP) {
|
||||
out.add(String.format(
|
||||
Locale.ROOT, "level data ≈ %.1f MB of 16 MB level memory", heapBytes / 1048576.0));
|
||||
}
|
||||
if (pages > 100 && pages <= 127) {
|
||||
out.add(pages + " of 127 texture pages used");
|
||||
}
|
||||
if (samples > 110 && samples <= SoundMerge.MAX_SAMPLES) {
|
||||
out.add(samples + " of " + SoundMerge.MAX_SAMPLES + " sound samples used");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public String summary() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"code+models %.1f MB · %d/127 texture pages · %d textures · %d/%d sounds · %d texts · level memory ≈ %.1f/16 MB",
|
||||
stpcBytes / 1048576.0,
|
||||
pages,
|
||||
textures,
|
||||
samples,
|
||||
SoundMerge.MAX_SAMPLES,
|
||||
texts,
|
||||
heapBytes / 1048576.0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current limits of a scene (after its imports). Level memory: the loader keeps STPC, TEXT
|
||||
* (compressed pages + 20 bytes per entry), TRAK, MAP, LGPC and the strat pool in one 16 MB block
|
||||
* (FUN_0041ef00); this sums those chunk sizes (an estimate, sound banks are not in it).
|
||||
*/
|
||||
public static Limits limits(Scene s) {
|
||||
long heap = 0;
|
||||
int stpc = 0;
|
||||
for (RawChunk c : s.chunks()) {
|
||||
switch (c.tag()) {
|
||||
case "STPC" -> {
|
||||
stpc = c.data().length;
|
||||
heap += stpc;
|
||||
}
|
||||
case "SMPC", "AMPC", "SRPC", "END ", "TEXT", "TRAK", "MAP " -> {}
|
||||
default -> heap += c.data() == null ? 0 : c.data().length;
|
||||
}
|
||||
}
|
||||
for (TexturePage p : s.pages()) {
|
||||
heap += p.encoded() != null ? p.encoded().length : (long) p.width() * p.height();
|
||||
}
|
||||
heap += 20L * s.textures().size();
|
||||
for (TileModel m : s.tileModels()) {
|
||||
heap += 0x84
|
||||
+ m.vertexCount() * 24L
|
||||
+ m.faceCount() * 28L
|
||||
+ 32L * (m.collisionCount(0) + m.collisionCount(1) + m.collisionCount(2));
|
||||
}
|
||||
heap += (long) s.tiles().size() * 64
|
||||
+ (long) s.settings().width() * s.settings().height() * 8;
|
||||
heap += (s.strats().size() + 30L + Math.max(0, s.settings().poolReserve())) * 0x154;
|
||||
RawChunk sm = chunk(s, "SMPC"), lg = chunk(s, "LGPC"), sr = chunk(s, "SRPC");
|
||||
int samples = sm == null ? 0 : SoundMerge.parse(sm.data()).size();
|
||||
int texts = lg == null ? 0 : DialogMerge.Texts.parse(lg.data()).texts.size();
|
||||
int speech =
|
||||
sr == null ? 0 : DialogMerge.Speech.parse(sr.data()).entries.size();
|
||||
return new Limits(stpc, s.pages().size(), s.textures().size(), samples, speech, texts, heap);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ merge
|
||||
|
||||
record SourceLevel(StpcMerge.Source source, Level level) {}
|
||||
|
||||
private static final Map<String, SoftReference<SourceLevel>> SOURCES = new ConcurrentHashMap<>();
|
||||
|
||||
static SourceLevel source(Bases cd, String level) throws IOException {
|
||||
String k = level.toLowerCase(Locale.ROOT);
|
||||
SoftReference<SourceLevel> r = SOURCES.get(k);
|
||||
SourceLevel s = r == null ? null : r.get();
|
||||
if (s != null) {
|
||||
return s;
|
||||
}
|
||||
Level lv = Level.parse(cd.wad(k));
|
||||
byte[] st = lv.wad().require("STPC");
|
||||
Set<Integer> roots = new TreeSet<>();
|
||||
for (MapChunk.Strat x : lv.map().strats) {
|
||||
roots.add(x.code());
|
||||
}
|
||||
StpcMerge.Analysis a = new StpcMerge.Analysis(new StpcImage(st, lv.wfpc()), roots);
|
||||
s = new SourceLevel(
|
||||
new StpcMerge.Source(
|
||||
k,
|
||||
a,
|
||||
TextMerge.Textures.of(lv.wad().require("TEXT")),
|
||||
SoundMerge.parse(lv.wad().get("SMPC")),
|
||||
lv.wad().get("LGPC") == null
|
||||
? null
|
||||
: DialogMerge.Texts.parse(lv.wad().get("LGPC")),
|
||||
DialogMerge.Speech.parse(lv.wad().get("SRPC"))),
|
||||
lv);
|
||||
SOURCES.put(k, new SoftReference<>(s));
|
||||
return s;
|
||||
}
|
||||
|
||||
static RawChunk chunk(Scene s, String tag) {
|
||||
for (RawChunk c : s.chunks()) {
|
||||
if (c.tag().equals(tag)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Analysis of a scene's (possibly already merged) STPC. */
|
||||
static StpcMerge.Analysis analysis(Scene s) {
|
||||
RawChunk c = chunk(s, "STPC");
|
||||
if (c == null) {
|
||||
throw new IllegalStateException("the level has no STPC chunk");
|
||||
}
|
||||
Set<Integer> roots = new TreeSet<>();
|
||||
for (Strat x : s.strats()) {
|
||||
roots.add(x.code());
|
||||
}
|
||||
roots.addAll(s.catalog().names().keySet());
|
||||
StpcImage img = new StpcImage(c.data(), s.settings().wfpc());
|
||||
roots.removeIf(o -> o < img.codeStart || img.unitOf(o) < 0);
|
||||
return new StpcMerge.Analysis(img, roots);
|
||||
}
|
||||
|
||||
static TextMerge.Textures textures(Scene s) {
|
||||
List<TextChunk.Page> pages = new ArrayList<>();
|
||||
for (TexturePage p : s.pages()) {
|
||||
pages.add(new TextChunk.Page(p.flags(), p.width(), p.height(), p.pixels()));
|
||||
}
|
||||
List<TextChunk.TextureEntry> entries = new ArrayList<>();
|
||||
for (TextureEntry e : s.textures()) {
|
||||
entries.add(new TextChunk.TextureEntry(
|
||||
(int) e.get("flags"),
|
||||
(int) e.get("page"),
|
||||
(int) e.get("page2"),
|
||||
(int) e.get("u0"),
|
||||
(int) e.get("u1"),
|
||||
(int) e.get("v0"),
|
||||
(int) e.get("v1")));
|
||||
}
|
||||
List<TextMerge.Anim> anims = new ArrayList<>();
|
||||
for (TextureAnim a : s.textureAnims()) {
|
||||
anims.add(new TextMerge.Anim(a.texture(), a.type(), a.data()));
|
||||
}
|
||||
return new TextMerge.Textures(pages, entries, anims);
|
||||
}
|
||||
|
||||
/** Merges a batch into (a copy of) the scene's level data; the scene is not changed. */
|
||||
public static Outcome compute(Scene s, List<TypeRef> batch, Bases cd) throws IOException {
|
||||
StpcMerge.Analysis tgt = analysis(s);
|
||||
TextMerge.Textures tex = textures(s);
|
||||
int p0 = tex.pages.size(), e0 = tex.entries.size(), a0 = tex.anims.size();
|
||||
RawChunk sm = chunk(s, "SMPC");
|
||||
List<SoundMerge.Sample> snd = SoundMerge.parse(sm == null ? null : sm.data());
|
||||
RawChunk lg = chunk(s, "LGPC"), sr = chunk(s, "SRPC");
|
||||
DialogMerge.Texts texts = lg == null ? null : DialogMerge.Texts.parse(lg.data());
|
||||
DialogMerge.Speech speech = DialogMerge.Speech.parse(sr == null ? null : sr.data());
|
||||
List<StpcMerge.Request> reqs = new ArrayList<>();
|
||||
Map<TypeRef, StpcMerge.Request> byRef = new LinkedHashMap<>();
|
||||
for (TypeRef t : batch) {
|
||||
SourceLevel src = source(cd, t.level);
|
||||
StpcMerge.Request r = new StpcMerge.Request(src.source(), t.code);
|
||||
reqs.add(r);
|
||||
byRef.put(t, r);
|
||||
}
|
||||
StpcMerge.Result res = StpcMerge.merge(tgt, reqs, new StpcMerge.Target(tex, snd, texts, speech));
|
||||
Map<TypeRef, Integer> codes = new LinkedHashMap<>();
|
||||
Map<TypeRef, StratCatalog.Variant> variants = new LinkedHashMap<>();
|
||||
for (var e : byRef.entrySet()) {
|
||||
int code = res.entries().get(e.getValue());
|
||||
codes.put(e.getKey(), code);
|
||||
variants.put(e.getKey(), variant(source(cd, e.getKey().level).level(), e.getKey(), code));
|
||||
}
|
||||
List<String> warn = new ArrayList<>(res.warnings());
|
||||
return new Outcome(
|
||||
res.stpc(),
|
||||
sm == null ? null : SoundMerge.build(snd),
|
||||
lg == null || res.stats().addedTexts() == 0 ? null : texts.build(),
|
||||
sr == null || res.stats().addedTexts() == 0 ? null : speech.build(),
|
||||
res.target(),
|
||||
new ArrayList<>(tex.pages.subList(p0, tex.pages.size())),
|
||||
new ArrayList<>(tex.entries.subList(e0, tex.entries.size())),
|
||||
new ArrayList<>(tex.anims.subList(a0, tex.anims.size())),
|
||||
codes,
|
||||
variants,
|
||||
res.names(),
|
||||
res.stats(),
|
||||
warn);
|
||||
}
|
||||
|
||||
/** Catalog prototype of the source level's first instance of the entry (type fields, params, waypoints). */
|
||||
static StratCatalog.Variant variant(Level lv, TypeRef t, int newCode) {
|
||||
MapChunk map = lv.map();
|
||||
for (MapChunk.Strat s : map.strats) {
|
||||
if (s.code() != t.code) {
|
||||
continue;
|
||||
}
|
||||
int[] prm = s.nParams() > 0 && s.paramIndex() >= 0 && s.paramIndex() + s.nParams() <= map.params.length
|
||||
? Arrays.copyOfRange(map.params, s.paramIndex(), s.paramIndex() + s.nParams())
|
||||
: new int[0];
|
||||
List<int[]> chain = new ArrayList<>();
|
||||
int k = s.waypoint();
|
||||
while (k >= 0 && k < map.nodes.size() && chain.size() <= 200) {
|
||||
MapChunk.PathNode n = map.nodes.get(k);
|
||||
chain.add(new int[] {
|
||||
n.x() - s.x(), n.y() - s.y(), n.z() - s.z(), n.rx(), n.ry(), n.rz(), n.unk1(), n.unk2(), n.unk3()
|
||||
});
|
||||
if (n.next() == 0) {
|
||||
break;
|
||||
}
|
||||
k = n.next();
|
||||
}
|
||||
return new StratCatalog.Variant(
|
||||
newCode,
|
||||
s.nVars(),
|
||||
s.slots(),
|
||||
s.typeFlags(),
|
||||
s.extra(),
|
||||
s.flags() & 0xffff,
|
||||
s.rx(),
|
||||
s.ry(),
|
||||
s.rz(),
|
||||
prm,
|
||||
List.copyOf(chain),
|
||||
t.level);
|
||||
}
|
||||
throw new IllegalArgumentException(t.type + " has no instance in " + t.level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an outcome into a scene. pageAt/texAt/animAt: where the new pages/entries/animations go
|
||||
* (−1 = end); items behind them (user textures) are renumbered.
|
||||
*/
|
||||
static void apply(Outcome o, Scene s, boolean events, int pageAt, int texAt, int animAt) {
|
||||
Runnable r = () -> {
|
||||
StratCatalog old = s.catalog();
|
||||
StratCatalog c = new StratCatalog();
|
||||
for (StratCatalog.Type t : old.types()) {
|
||||
for (StratCatalog.Variant v : t.variants()) {
|
||||
c.add(t.name(), withCode(v, o.target.map(v.code())));
|
||||
}
|
||||
}
|
||||
for (var e : o.variants.entrySet()) {
|
||||
String name = e.getKey().type.isEmpty() ? "?" : e.getKey().type;
|
||||
c.add(name, e.getValue());
|
||||
}
|
||||
for (var e : old.names().entrySet()) {
|
||||
int m = o.target.map(e.getKey());
|
||||
if (old.codeOnly().contains(e.getValue()) && !c.has(e.getValue())) {
|
||||
c.addCodeOnly(e.getValue(), m);
|
||||
} else {
|
||||
c.addName(m, e.getValue());
|
||||
}
|
||||
}
|
||||
o.names.forEach((code, name) -> {
|
||||
if (!c.has(name)) {
|
||||
c.addCodeOnly(name, code);
|
||||
} else {
|
||||
c.addName(code, name);
|
||||
}
|
||||
});
|
||||
s.setCatalog(c);
|
||||
chunk(s, "STPC").setRaw("data", o.stpc);
|
||||
if (o.smpc != null) {
|
||||
chunk(s, "SMPC").setRaw("data", o.smpc);
|
||||
}
|
||||
if (o.lgpc != null) {
|
||||
chunk(s, "LGPC").setRaw("data", o.lgpc);
|
||||
}
|
||||
if (o.srpc != null && chunk(s, "SRPC") != null) {
|
||||
chunk(s, "SRPC").setRaw("data", o.srpc);
|
||||
}
|
||||
for (Strat x : s.strats()) {
|
||||
int m = o.target.map(x.code());
|
||||
if (m >= 0 && m != x.code()) {
|
||||
x.setRaw("code", m);
|
||||
x.setTypeName(s.catalog().nameOf(m));
|
||||
}
|
||||
}
|
||||
insertTextures(o, s, pageAt, texAt, animAt);
|
||||
Map<Integer, TypeRef> codes = new java.util.HashMap<>();
|
||||
Map<Integer, TypeRef> prev = CODES.get(s);
|
||||
if (prev != null) {
|
||||
prev.forEach((k, v) -> codes.put(o.target.map(k), v));
|
||||
}
|
||||
o.codes.forEach((t, k) -> codes.put(k, t));
|
||||
CODES.put(s, codes);
|
||||
};
|
||||
if (events) {
|
||||
s.bulk(r);
|
||||
} else {
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
|
||||
static StratCatalog.Variant withCode(StratCatalog.Variant v, int code) {
|
||||
return new StratCatalog.Variant(
|
||||
code,
|
||||
v.nVars(),
|
||||
v.slots(),
|
||||
v.typeFlags(),
|
||||
v.extra(),
|
||||
v.flags(),
|
||||
v.rx(),
|
||||
v.ry(),
|
||||
v.rz(),
|
||||
v.params(),
|
||||
v.chain(),
|
||||
v.sourceLevel());
|
||||
}
|
||||
|
||||
private static final Pattern TEX_REF = Pattern.compile("#(\\d+)");
|
||||
|
||||
private static void insertTextures(Outcome o, Scene s, int pageAt, int texAt, int animAt) {
|
||||
int np = o.pages.size(), ne = o.entries.size(), na = o.anims.size();
|
||||
int pAt = pageAt < 0 ? s.pages().size() : pageAt;
|
||||
int tAt = texAt < 0 ? s.textures().size() : texAt;
|
||||
int aAt = animAt < 0 ? s.textureAnims().size() : animAt;
|
||||
boolean shiftPages = pAt < s.pages().size(),
|
||||
shiftTex = tAt < s.textures().size();
|
||||
if (shiftPages) {
|
||||
for (TextureEntry e : s.textures()) {
|
||||
int pg = (int) e.get("page");
|
||||
if (pg >= pAt) {
|
||||
e.setRaw("page", pg + np);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shiftTex && ne > 0) {
|
||||
for (TileModel m : s.tileModels()) {
|
||||
int[] f = m.faces().clone();
|
||||
boolean ch = false;
|
||||
for (int i = 4; i < f.length; i += 6) {
|
||||
if (f[i] >= tAt) {
|
||||
f[i] += ne;
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if (ch) {
|
||||
m.setRaw("faces", f);
|
||||
}
|
||||
}
|
||||
for (TextureAnim a : s.textureAnims()) {
|
||||
if (a.texture() >= tAt) {
|
||||
a.setRaw("texture", a.texture() + ne);
|
||||
}
|
||||
byte[] d = a.data().clone();
|
||||
boolean ch = false;
|
||||
int[][] fr = new TextMerge.Anim(a.texture(), a.type(), d).frames();
|
||||
for (int[] f : fr) {
|
||||
if (f[0] >= tAt) {
|
||||
int v = f[0] + ne;
|
||||
d[f[1]] = (byte) v;
|
||||
d[f[1] + 1] = (byte) (v >> 8);
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if (ch) {
|
||||
a.setRaw("data", d);
|
||||
}
|
||||
}
|
||||
for (Brush b : s.brushes()) {
|
||||
for (Prop p : b.props()) {
|
||||
if (p.type() == PropType.STRING && p.get(b) instanceof String str && str.startsWith("#")) {
|
||||
Matcher m = TEX_REF.matcher(str);
|
||||
if (m.matches() && Integer.parseInt(m.group(1)) >= tAt) {
|
||||
p.setRaw(b, "#" + (Integer.parseInt(m.group(1)) + ne));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
int base = countImported(s, "page:x");
|
||||
for (int i = 0; i < np; i++) {
|
||||
TextChunk.Page p = o.pages.get(i);
|
||||
TexturePage n = new TexturePage("page:x" + (base + i));
|
||||
n.setRaw("flags", p.flags);
|
||||
n.setRaw("width", p.w);
|
||||
n.setRaw("height", p.h);
|
||||
n.setRaw("pixels", p.pixels);
|
||||
s.add(n, pAt + i);
|
||||
}
|
||||
int tb = countImported(s, "tex:x");
|
||||
for (int i = 0; i < ne; i++) {
|
||||
TextChunk.TextureEntry e = o.entries.get(i);
|
||||
TextureEntry n = new TextureEntry("tex:x" + (tb + i));
|
||||
n.setRaw("flags", e.flags());
|
||||
n.setRaw("page", e.page());
|
||||
n.setRaw("page2", e.page2());
|
||||
n.setRaw("u0", e.u0());
|
||||
n.setRaw("u1", e.u1());
|
||||
n.setRaw("v0", e.v0());
|
||||
n.setRaw("v1", e.v1());
|
||||
s.add(n, tAt + i);
|
||||
}
|
||||
int ab = countImported(s, "anim:x");
|
||||
for (int i = 0; i < na; i++) {
|
||||
TextMerge.Anim a = o.anims.get(i);
|
||||
TextureAnim n = new TextureAnim("anim:x" + (ab + i));
|
||||
n.setRaw("texture", a.texture());
|
||||
n.setRaw("type", a.type());
|
||||
n.setRaw("data", a.data());
|
||||
s.add(n, aAt + i);
|
||||
}
|
||||
}
|
||||
|
||||
private static int countImported(Scene s, String prefix) {
|
||||
int n = 0;
|
||||
for (Node x : s.pages()) {
|
||||
n += x.id().startsWith(prefix) ? 1 : 0;
|
||||
}
|
||||
for (Node x : s.textures()) {
|
||||
n += x.id().startsWith(prefix) ? 1 : 0;
|
||||
}
|
||||
for (Node x : s.textureAnims()) {
|
||||
n += x.id().startsWith(prefix) ? 1 : 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.io.Project;
|
||||
import com.eliasmueller.llamamod.editor.ui.Document;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.SwingWorker;
|
||||
import javax.swing.table.AbstractTableModel;
|
||||
|
||||
/**
|
||||
* "Import world…": many object types of one world (all its chapters) or one level at once, for
|
||||
* fresh maps. Shows the dependency sizes and, after "Preview", the exact merge result against the
|
||||
* engine limits.
|
||||
*/
|
||||
final class ImportWorldDialog extends JDialog {
|
||||
private final EditorContext ctx;
|
||||
private final JComboBox<String> source = new JComboBox<>();
|
||||
private final List<Object> sourceKeys = new ArrayList<>();
|
||||
private final JCheckBox skipKnown = new JCheckBox("Skip types the level already has", true);
|
||||
private final Rows rows = new Rows();
|
||||
private final JTable table = new JTable(rows);
|
||||
private final JLabel preview = new JLabel(" ");
|
||||
private final JButton importBtn = new JButton("Import", Icons.get("import", 14));
|
||||
private SwingWorker<?, ?> worker;
|
||||
|
||||
static void show(EditorContext ctx, Object initial) {
|
||||
if (ctx.document() == null) {
|
||||
JOptionPane.showMessageDialog(
|
||||
ctx.frame(), "Open a level first.", "Import world", JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (CatalogStore.get() == null) {
|
||||
CatalogStore.ensure(ctx);
|
||||
JOptionPane.showMessageDialog(
|
||||
ctx.frame(),
|
||||
"The object catalogue is still being built – try again in a moment.",
|
||||
"Import world",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
ImportWorldDialog d = new ImportWorldDialog(ctx, initial);
|
||||
d.setVisible(true);
|
||||
}
|
||||
|
||||
private ImportWorldDialog(EditorContext ctx, Object initial) {
|
||||
super(ctx.frame(), "Import objects of a world", true);
|
||||
this.ctx = ctx;
|
||||
ObjectCatalog c = CatalogStore.get();
|
||||
for (int w = 1; w <= 8; w++) {
|
||||
source.addItem(Worlds.name(w) + " – all chapters");
|
||||
sourceKeys.add(w);
|
||||
}
|
||||
Map<String, String> levels = new LinkedHashMap<>();
|
||||
for (ObjectCatalog.Entry e : c.entries()) {
|
||||
levels.putIfAbsent(e.level(), e.levelName() + " (" + e.level() + ")");
|
||||
}
|
||||
levels.forEach((k, v) -> {
|
||||
source.addItem(v);
|
||||
sourceKeys.add(k);
|
||||
});
|
||||
if (initial instanceof Integer w && w >= 1 && w <= 8) {
|
||||
source.setSelectedIndex(w - 1);
|
||||
} else if (initial instanceof String l && sourceKeys.contains(l)) {
|
||||
source.setSelectedIndex(sourceKeys.indexOf(l));
|
||||
}
|
||||
source.addActionListener(e -> fill());
|
||||
skipKnown.addActionListener(e -> fill());
|
||||
JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 6));
|
||||
top.add(new JLabel("Source:"));
|
||||
top.add(source);
|
||||
top.add(skipKnown);
|
||||
JButton all = new JButton("All");
|
||||
all.addActionListener(e -> rows.setAll(true));
|
||||
JButton none = new JButton("None");
|
||||
none.addActionListener(e -> rows.setAll(false));
|
||||
top.add(all);
|
||||
top.add(none);
|
||||
|
||||
table.setRowHeight(22);
|
||||
table.getColumnModel().getColumn(0).setMaxWidth(36);
|
||||
table.getColumnModel().getColumn(1).setPreferredWidth(200);
|
||||
table.getColumnModel().getColumn(2).setPreferredWidth(170);
|
||||
table.getColumnModel().getColumn(3).setPreferredWidth(170);
|
||||
table.getColumnModel().getColumn(4).setPreferredWidth(70);
|
||||
table.getColumnModel().getColumn(5).setPreferredWidth(320);
|
||||
rows.addTableModelListener(e -> preview.setText(rows.summary()));
|
||||
|
||||
JButton prev = new JButton("Preview");
|
||||
prev.setToolTipText("Merge in the background and show the exact sizes against the engine limits");
|
||||
prev.addActionListener(e -> preview());
|
||||
importBtn.addActionListener(e -> doImport());
|
||||
JButton cancel = new JButton("Cancel");
|
||||
cancel.addActionListener(e -> dispose());
|
||||
JPanel bottom = new JPanel(new BorderLayout());
|
||||
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||
bottom.add(preview, BorderLayout.CENTER);
|
||||
JPanel btns = new JPanel(new FlowLayout(FlowLayout.RIGHT));
|
||||
btns.add(prev);
|
||||
btns.add(importBtn);
|
||||
btns.add(cancel);
|
||||
bottom.add(btns, BorderLayout.SOUTH);
|
||||
JPanel root = new JPanel(new BorderLayout());
|
||||
root.add(top, BorderLayout.NORTH);
|
||||
root.add(new JScrollPane(table), BorderLayout.CENTER);
|
||||
root.add(bottom, BorderLayout.SOUTH);
|
||||
setContentPane(root);
|
||||
setSize(new Dimension(1060, 640));
|
||||
setLocationRelativeTo(ctx.frame());
|
||||
fill();
|
||||
}
|
||||
|
||||
private void fill() {
|
||||
ObjectCatalog c = CatalogStore.get();
|
||||
Object key = sourceKeys.get(Math.max(0, source.getSelectedIndex()));
|
||||
Map<String, ObjectCatalog.Entry> byName = new LinkedHashMap<>();
|
||||
for (ObjectCatalog.Entry e : c.entries()) {
|
||||
boolean match =
|
||||
key instanceof Integer w ? e.world() == w : e.level().equals(key);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (skipKnown.isSelected()
|
||||
&& ctx.scene() != null
|
||||
&& ctx.scene().catalog().has(e.type())) {
|
||||
continue;
|
||||
}
|
||||
byName.putIfAbsent(e.type(), e);
|
||||
}
|
||||
rows.set(new ArrayList<>(byName.values()));
|
||||
preview.setText(rows.summary());
|
||||
}
|
||||
|
||||
private List<ObjectCatalog.Entry> chosen() {
|
||||
List<ObjectCatalog.Entry> out = new ArrayList<>();
|
||||
for (int i = 0; i < rows.list.size(); i++) {
|
||||
if (rows.on.get(i) && rows.list.get(i).importable()) {
|
||||
out.add(rows.list.get(i));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void preview() {
|
||||
Document d = ctx.document();
|
||||
List<ObjectCatalog.Entry> ch = chosen();
|
||||
if (d == null || ch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<CrossImport.TypeRef> batch = new ArrayList<>();
|
||||
for (ObjectCatalog.Entry e : ch) {
|
||||
if (Importer.importedCode(ctx, Importer.ref(e)) < 0) {
|
||||
batch.add(Importer.ref(e));
|
||||
}
|
||||
}
|
||||
Project p = d.project();
|
||||
CrossImport.Limits before = CrossImport.limits(p.scene());
|
||||
preview.setText("Merging " + batch.size() + " types…");
|
||||
if (worker != null) {
|
||||
worker.cancel(true);
|
||||
}
|
||||
worker = new SwingWorker<CrossImport.Outcome, Void>() {
|
||||
@Override
|
||||
protected CrossImport.Outcome doInBackground() throws Exception {
|
||||
return CrossImport.compute(p.reference(), batch, d.bases());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
try {
|
||||
CrossImport.Outcome o = get();
|
||||
preview.setText(describe(before, o));
|
||||
} catch (Exception ex) {
|
||||
if (!isCancelled()) {
|
||||
Throwable t = ex.getCause() != null ? ex.getCause() : ex;
|
||||
preview.setText(
|
||||
"<html><font color='#d93f3f'>Cannot import: " + t.getMessage() + "</font></html>");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
worker.execute();
|
||||
}
|
||||
|
||||
static String describe(CrossImport.Limits b, CrossImport.Outcome o) {
|
||||
StpcMergeStats s = new StpcMergeStats(o);
|
||||
CrossImport.Limits a = new CrossImport.Limits(
|
||||
o.stpc().length,
|
||||
b.pages() + o.pages().size(),
|
||||
b.textures() + o.entries().size(),
|
||||
o.smpc() == null
|
||||
? b.samples()
|
||||
: com.eliasmueller.llamamod.format.stpcmerge.SoundMerge.parse(o.smpc())
|
||||
.size(),
|
||||
b.speech(),
|
||||
b.texts() + o.stats().addedTexts(),
|
||||
b.heapBytes()
|
||||
+ (o.stpc().length - b.stpcBytes())
|
||||
+ 65536L * o.pages().size()
|
||||
+ 20L * o.entries().size());
|
||||
StringBuilder sb = new StringBuilder("<html>");
|
||||
sb.append(String.format(
|
||||
Locale.ROOT,
|
||||
"Adds %d code units (%d shared with the level), %d models, %d animations, %d texture pages, %d textures, %d sounds, %d texts: +%.0f KB<br>",
|
||||
s.units,
|
||||
s.shared,
|
||||
o.stats().models(),
|
||||
o.stats().anims(),
|
||||
o.pages().size(),
|
||||
o.entries().size(),
|
||||
o.stats().addedSamples(),
|
||||
o.stats().addedTexts(),
|
||||
o.stats().bytesAdded() / 1024.0));
|
||||
sb.append("After: ").append(a.summary());
|
||||
for (String p : a.problems()) {
|
||||
sb.append("<br><font color='#d93f3f'>✗ ").append(p).append("</font>");
|
||||
}
|
||||
for (String w : a.warnings()) {
|
||||
sb.append("<br><font color='#e0a820'>! ").append(w).append("</font>");
|
||||
}
|
||||
for (String w : o.warnings()) {
|
||||
sb.append("<br>• ").append(w);
|
||||
}
|
||||
return sb.append("</html>").toString();
|
||||
}
|
||||
|
||||
private record StpcMergeStats(int units, int shared) {
|
||||
StpcMergeStats(CrossImport.Outcome o) {
|
||||
this(o.stats().units(), o.stats().sharedUnits());
|
||||
}
|
||||
}
|
||||
|
||||
private void doImport() {
|
||||
List<ObjectCatalog.Entry> ch = chosen();
|
||||
if (ch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
dispose();
|
||||
Importer.importTypes(ctx, ch, o -> {
|
||||
if (o != null) {
|
||||
ctx.status("Imported " + o.codes().size() + " object types – find them in Assets → Objects. "
|
||||
+ CrossImport.limits(ctx.scene()).summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class Rows extends AbstractTableModel {
|
||||
final List<ObjectCatalog.Entry> list = new ArrayList<>();
|
||||
final List<Boolean> on = new ArrayList<>();
|
||||
|
||||
void set(List<ObjectCatalog.Entry> l) {
|
||||
list.clear();
|
||||
on.clear();
|
||||
list.addAll(l);
|
||||
for (ObjectCatalog.Entry e : l) {
|
||||
on.add(e.importable()
|
||||
&& !e.category().equals("trigger")
|
||||
&& !e.category().equals("force"));
|
||||
}
|
||||
fireTableDataChanged();
|
||||
}
|
||||
|
||||
void setAll(boolean v) {
|
||||
for (int i = 0; i < on.size(); i++) {
|
||||
on.set(i, v && list.get(i).importable());
|
||||
}
|
||||
fireTableDataChanged();
|
||||
}
|
||||
|
||||
String summary() {
|
||||
int n = 0;
|
||||
long b = 0;
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
if (on.get(i)) {
|
||||
n++;
|
||||
b += list.get(i).bytes();
|
||||
}
|
||||
}
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"%d of %d types selected, ≤ %.0f KB before sharing with the level (Preview for exact numbers)",
|
||||
n,
|
||||
list.size(),
|
||||
b / 1024.0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowCount() {
|
||||
return list.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColumnCount() {
|
||||
return 6;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getColumnName(int c) {
|
||||
return new String[] {"", "Type", "Category", "Level", "Size", "Notes"}[c];
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> getColumnClass(int c) {
|
||||
return c == 0 ? Boolean.class : String.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCellEditable(int r, int c) {
|
||||
return c == 0 && list.get(r).importable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getValueAt(int r, int c) {
|
||||
ObjectCatalog.Entry e = list.get(r);
|
||||
return switch (c) {
|
||||
case 0 -> on.get(r);
|
||||
case 1 -> e.type();
|
||||
case 2 -> ObjectCatalog.categoryLabel(e.category());
|
||||
case 3 -> e.levelName();
|
||||
case 4 -> e.bytes() / 1024 + " KB";
|
||||
default -> e.importable() ? String.join("; ", e.notes()) : "✗ " + String.join("; ", e.reasons());
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setValueAt(Object v, int r, int c) {
|
||||
if (c == 0) {
|
||||
on.set(r, Boolean.TRUE.equals(v));
|
||||
fireTableRowsUpdated(r, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.io.Project;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.StratCatalog;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Command;
|
||||
import com.eliasmueller.llamamod.editor.ui.Document;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Editor side of cross-level imports: background merge, commit, placement. */
|
||||
final class Importer {
|
||||
private Importer() {}
|
||||
|
||||
static CrossImport.TypeRef ref(ObjectCatalog.Entry e) {
|
||||
return new CrossImport.TypeRef(e.level(), e.type(), e.code());
|
||||
}
|
||||
|
||||
/** Variant index of the catalog type whose code equals the imported entry, or −1. */
|
||||
static int variant(Scene s, String type, int code) {
|
||||
StratCatalog.Type t = s.catalog().type(type);
|
||||
if (t == null) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < t.variants().size(); i++) {
|
||||
if (t.variants().get(i).code() == code) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Code of an already imported type in this project, or −1. */
|
||||
static int importedCode(EditorContext ctx, CrossImport.TypeRef t) {
|
||||
Document d = ctx.document();
|
||||
if (d == null) {
|
||||
return -1;
|
||||
}
|
||||
java.util.Map<Integer, CrossImport.TypeRef> m =
|
||||
CrossImport.codes(d.project().scene());
|
||||
for (var e : m.entrySet()) {
|
||||
if (e.getValue().equals(t)) {
|
||||
return e.getKey();
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the entries not yet imported (in the background), then calls done on the Swing thread
|
||||
* with the outcome (null if nothing had to be imported).
|
||||
*/
|
||||
static void importTypes(EditorContext ctx, List<ObjectCatalog.Entry> entries, Consumer<CrossImport.Outcome> done) {
|
||||
Document d = ctx.document();
|
||||
if (d == null) {
|
||||
return;
|
||||
}
|
||||
List<CrossImport.TypeRef> batch = new ArrayList<>();
|
||||
for (ObjectCatalog.Entry e : entries) {
|
||||
CrossImport.TypeRef t = ref(e);
|
||||
if (e.importable() && importedCode(ctx, t) < 0 && !batch.contains(t)) {
|
||||
batch.add(t);
|
||||
}
|
||||
}
|
||||
if (batch.isEmpty()) {
|
||||
done.accept(null);
|
||||
return;
|
||||
}
|
||||
Project p = d.project();
|
||||
String what = batch.size() == 1 ? batch.get(0).type() : batch.size() + " object types";
|
||||
ctx.runTask(
|
||||
"Importing " + what,
|
||||
pr -> {
|
||||
pr.update(0.1, "Merging " + what + " into the level");
|
||||
return CrossImport.compute(p.reference(), batch, d.bases());
|
||||
},
|
||||
res -> {
|
||||
if (!(res instanceof CrossImport.Outcome o) || ctx.document() != d) {
|
||||
return;
|
||||
}
|
||||
CrossImport.commit(p, batch, o);
|
||||
d.history().clear();
|
||||
ctx.execute(new ImportMark(CrossImport.names(batch)));
|
||||
CrossImport.Limits lim = CrossImport.limits(p.scene());
|
||||
ctx.status(String.format(
|
||||
Locale.ROOT,
|
||||
"Imported %s: +%d KB code/models, +%d textures, +%d sounds, +%d texts · %s",
|
||||
what,
|
||||
o.stats().bytesAdded() / 1024,
|
||||
o.stats().addedTextures(),
|
||||
o.stats().addedSamples(),
|
||||
o.stats().addedTexts(),
|
||||
lim.summary()));
|
||||
done.accept(o);
|
||||
});
|
||||
}
|
||||
|
||||
/** Imports (if needed) and places one instance at ground (export space). */
|
||||
static void importAndPlace(EditorContext ctx, ObjectCatalog.Entry e, Vector3f ground) {
|
||||
if (!e.importable()) {
|
||||
ctx.status(e.type() + " cannot be imported: " + String.join("; ", e.reasons()));
|
||||
return;
|
||||
}
|
||||
importTypes(ctx, List.of(e), o -> {
|
||||
int code = importedCode(ctx, ref(e));
|
||||
int v = code < 0 ? -1 : variant(ctx.scene(), e.type(), code);
|
||||
if (v < 0) {
|
||||
ctx.status(e.type() + ": imported, but no placeable variant found");
|
||||
return;
|
||||
}
|
||||
EditOps.placeObject(ctx, e.type(), v, ground);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo-history marker for an import. Imports move code offsets and cannot be undone, so the
|
||||
* history is cleared before; undoing this entry does nothing.
|
||||
*/
|
||||
record ImportMark(String what) implements Command {
|
||||
@Override
|
||||
public String label() {
|
||||
return "Import " + what + " (permanent)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.catalog;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.io.Bases;
|
||||
import com.eliasmueller.llamamod.editor.model.StratCatalog;
|
||||
import com.eliasmueller.llamamod.editor.render.LevelSceneAdapter;
|
||||
import com.eliasmueller.llamamod.editor.render.RenderMesh;
|
||||
import com.eliasmueller.llamamod.editor.render.StratModels;
|
||||
import com.eliasmueller.llamamod.editor.render.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.ui.OriginalLevels;
|
||||
import com.eliasmueller.llamamod.editor.ui.view.Thumbnails;
|
||||
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.stpcmerge.StpcImage;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.BiConsumer;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/**
|
||||
* Global catalogue of the placeable object types of all levels on the CD: name, source levels,
|
||||
* category, dependency size, thumbnail and whether it can be imported (with reasons). Built once in
|
||||
* the background and cached in {@code <data>/editor/catalog/} (catalog.json + thumbs/*.png).
|
||||
*/
|
||||
public final class ObjectCatalog {
|
||||
static final int VERSION = 2;
|
||||
public static final int THUMB = 96;
|
||||
|
||||
/** One type of one level (the first instance's code entry). */
|
||||
public record Entry(
|
||||
String level,
|
||||
int world,
|
||||
int chapter,
|
||||
String levelName,
|
||||
String type,
|
||||
int code,
|
||||
String category,
|
||||
int instances,
|
||||
int params,
|
||||
boolean waypoints,
|
||||
int units,
|
||||
int models,
|
||||
int anims,
|
||||
int sounds,
|
||||
int textures,
|
||||
int bytes,
|
||||
boolean importable,
|
||||
List<String> reasons,
|
||||
List<String> notes,
|
||||
String thumb) {
|
||||
public String key() {
|
||||
return level + ":" + type + "@" + Integer.toHexString(code);
|
||||
}
|
||||
|
||||
Map<String, Object> json() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("level", level);
|
||||
m.put("world", (long) world);
|
||||
m.put("chapter", (long) chapter);
|
||||
m.put("levelName", levelName);
|
||||
m.put("type", type);
|
||||
m.put("code", (long) code);
|
||||
m.put("category", category);
|
||||
m.put("instances", (long) instances);
|
||||
m.put("params", (long) params);
|
||||
m.put("waypoints", waypoints);
|
||||
m.put("units", (long) units);
|
||||
m.put("models", (long) models);
|
||||
m.put("anims", (long) anims);
|
||||
m.put("sounds", (long) sounds);
|
||||
m.put("textures", (long) textures);
|
||||
m.put("bytes", (long) bytes);
|
||||
m.put("importable", importable);
|
||||
m.put("reasons", new ArrayList<Object>(reasons));
|
||||
m.put("notes", new ArrayList<Object>(notes));
|
||||
m.put("thumb", thumb == null ? "" : thumb);
|
||||
return m;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Entry of(Map<String, Object> m) {
|
||||
return new Entry(
|
||||
(String) m.get("level"),
|
||||
i(m, "world"),
|
||||
i(m, "chapter"),
|
||||
(String) m.get("levelName"),
|
||||
(String) m.get("type"),
|
||||
i(m, "code"),
|
||||
(String) m.get("category"),
|
||||
i(m, "instances"),
|
||||
i(m, "params"),
|
||||
Boolean.TRUE.equals(m.get("waypoints")),
|
||||
i(m, "units"),
|
||||
i(m, "models"),
|
||||
i(m, "anims"),
|
||||
i(m, "sounds"),
|
||||
i(m, "textures"),
|
||||
i(m, "bytes"),
|
||||
Boolean.TRUE.equals(m.get("importable")),
|
||||
(List<String>) (List<?>) m.getOrDefault("reasons", List.of()),
|
||||
(List<String>) (List<?>) m.getOrDefault("notes", List.of()),
|
||||
(String) m.get("thumb"));
|
||||
}
|
||||
|
||||
private static int i(Map<String, Object> m, String k) {
|
||||
Object v = m.get(k);
|
||||
return v instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private final List<Entry> entries;
|
||||
private final Path dir;
|
||||
|
||||
private ObjectCatalog(List<Entry> entries, Path dir) {
|
||||
this.entries = entries;
|
||||
this.dir = dir;
|
||||
}
|
||||
|
||||
public List<Entry> entries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public Path thumbFile(Entry e) {
|
||||
return e.thumb == null || e.thumb.isEmpty() || dir == null
|
||||
? null
|
||||
: dir.resolve("thumbs").resolve(e.thumb);
|
||||
}
|
||||
|
||||
/** Cached catalogue if it matches the CD, else null. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public static ObjectCatalog cached(Path dir, Path cd) {
|
||||
try {
|
||||
Path f = dir.resolve("catalog.json");
|
||||
if (!Files.isRegularFile(f)) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> j = (Map<String, Object>) Json.parse(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (((Number) j.getOrDefault("version", 0L)).intValue() != VERSION
|
||||
|| !fingerprint(cd).equals(j.get("cd"))) {
|
||||
return null;
|
||||
}
|
||||
List<Entry> out = new ArrayList<>();
|
||||
for (Object o : (List<Object>) j.get("entries")) {
|
||||
out.add(Entry.of((Map<String, Object>) o));
|
||||
}
|
||||
return new ObjectCatalog(out, dir);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Names and sizes of the CD's level WADs (cache key). */
|
||||
static String fingerprint(Path cd) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (OriginalLevels.Level l : OriginalLevels.list(cd)) {
|
||||
Path p = Bases.resolveIgnoreCase(cd, "wads/" + l.base() + ".wad");
|
||||
sb.append(l.base())
|
||||
.append(':')
|
||||
.append(p == null ? 0 : Files.size(p))
|
||||
.append(';');
|
||||
}
|
||||
return Integer.toHexString(sb.toString().hashCode()) + ":" + sb.length();
|
||||
}
|
||||
|
||||
/** Builds the catalogue (all levels), writes the cache; progress(done, total). */
|
||||
public static ObjectCatalog build(Path cd, Path dir, BiConsumer<Integer, Integer> progress) throws IOException {
|
||||
Bases bases = Bases.fromCd(cd);
|
||||
List<OriginalLevels.Level> levels = new ArrayList<>(OriginalLevels.list(cd));
|
||||
Files.createDirectories(dir.resolve("thumbs"));
|
||||
List<Entry> out = new ArrayList<>();
|
||||
int done = 0;
|
||||
for (OriginalLevels.Level l : levels) {
|
||||
progress.accept(done++, levels.size());
|
||||
byte[] wad = bases.fileOrNull("wads/" + l.base() + ".wad");
|
||||
if (wad == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
scanLevel(l, wad, dir, out);
|
||||
} catch (RuntimeException e) {
|
||||
// front-end WADs without MAP and the like
|
||||
}
|
||||
}
|
||||
progress.accept(levels.size(), levels.size());
|
||||
Map<String, Object> j = new LinkedHashMap<>();
|
||||
j.put("version", (long) VERSION);
|
||||
j.put("cd", fingerprint(cd));
|
||||
List<Object> es = new ArrayList<>();
|
||||
out.forEach(e -> es.add(e.json()));
|
||||
j.put("entries", es);
|
||||
Files.writeString(dir.resolve("catalog.json"), Json.write(j), StandardCharsets.UTF_8);
|
||||
return new ObjectCatalog(out, dir);
|
||||
}
|
||||
|
||||
static void scanLevel(OriginalLevels.Level l, byte[] wad, Path dir, List<Entry> out) throws IOException {
|
||||
Level lv = Level.parse(wad);
|
||||
byte[] st = lv.wad().require("STPC");
|
||||
StpcImage img = new StpcImage(st, lv.wfpc());
|
||||
TreeSet<Integer> roots = new TreeSet<>();
|
||||
Map<Integer, Integer> count = new LinkedHashMap<>();
|
||||
Map<Integer, MapChunk.Strat> first = new LinkedHashMap<>();
|
||||
for (MapChunk.Strat s : lv.map().strats) {
|
||||
roots.add(s.code());
|
||||
count.merge(s.code(), 1, Integer::sum);
|
||||
first.putIfAbsent(s.code(), s);
|
||||
}
|
||||
StpcMerge.Analysis a = new StpcMerge.Analysis(img, roots);
|
||||
byte[] text = lv.wad().require("TEXT");
|
||||
TextChunk tc = TextChunk.parse(text);
|
||||
List<TextChunk.TextureEntry> tex = TextChunk.textureEntries(text);
|
||||
int[] pageClass = new int[tc.pages.size()];
|
||||
List<TexturePage> pages = new ArrayList<>();
|
||||
for (int i = 0; i < tc.pages.size(); i++) {
|
||||
pageClass[i] = tc.pages.get(i).flags & 0x7f;
|
||||
pages.add(LevelSceneAdapter.page(tc.pages.get(i)));
|
||||
}
|
||||
StratModels sm = new StratModels(st, lv.wfpc());
|
||||
Map<String, Integer> seenName = new LinkedHashMap<>();
|
||||
for (var e : first.entrySet()) {
|
||||
int code = e.getKey();
|
||||
MapChunk.Strat s = e.getValue();
|
||||
String name = Strats.nameOr(st, code, img.codeStart, "?").replace("(spawn)", "");
|
||||
if (seenName.containsKey(name)) {
|
||||
continue; // further variants (spawn stubs) of a type: the first one represents it
|
||||
}
|
||||
seenName.put(name, code);
|
||||
StpcMerge.Closure c = StpcMerge.closure(a, code, null);
|
||||
List<String> reasons = new ArrayList<>(c.problems());
|
||||
List<String> notes = new ArrayList<>();
|
||||
String cat = category(name);
|
||||
if (cat.equals("player")) {
|
||||
reasons.add("player start: every level has its own");
|
||||
}
|
||||
if (cat.equals("camera") || name.startsWith("CutScene")) {
|
||||
reasons.add("camera / cut scene of its level");
|
||||
}
|
||||
if (c.text()) {
|
||||
notes.add("speaks/shows " + c.texts().size() + " texts of its level (copied with speech)");
|
||||
}
|
||||
if (c.tileSwap()) {
|
||||
notes.add("swaps tiles of its cell (needs alternative tile models there)");
|
||||
}
|
||||
if (s.waypoint() >= 0) {
|
||||
notes.add("uses a waypoint chain (copied relative to the object)");
|
||||
}
|
||||
String thumb = null;
|
||||
try {
|
||||
BufferedImage img2 = thumbnail(sm, code, name, tex, tc, pageClass, pages);
|
||||
if (img2 != null) {
|
||||
thumb = l.base() + "_" + Integer.toHexString(code) + ".png";
|
||||
ByteArrayOutputStream bo = new ByteArrayOutputStream();
|
||||
ImageIO.write(img2, "png", bo);
|
||||
Files.write(dir.resolve("thumbs").resolve(thumb), bo.toByteArray());
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
thumb = null;
|
||||
}
|
||||
out.add(new Entry(
|
||||
l.base(),
|
||||
l.world(),
|
||||
l.chapter(),
|
||||
l.name(),
|
||||
name,
|
||||
code,
|
||||
cat,
|
||||
count.get(code),
|
||||
s.nParams(),
|
||||
s.waypoint() >= 0,
|
||||
c.units().size(),
|
||||
c.models().size(),
|
||||
c.anims().size(),
|
||||
c.sounds().size(),
|
||||
c.textures().size(),
|
||||
c.bytes(),
|
||||
reasons.isEmpty(),
|
||||
reasons,
|
||||
notes,
|
||||
thumb));
|
||||
}
|
||||
}
|
||||
|
||||
/** Catalogue categories: the editor's ones, with "other" split by name. */
|
||||
public static final String[] CATEGORIES = {
|
||||
"enemy",
|
||||
"collectible",
|
||||
"mechanism",
|
||||
"character",
|
||||
"creature",
|
||||
"effect",
|
||||
"checkpoint",
|
||||
"exit",
|
||||
"force",
|
||||
"trigger",
|
||||
"camera",
|
||||
"player",
|
||||
"other"
|
||||
};
|
||||
|
||||
public static String categoryLabel(String c) {
|
||||
return switch (c) {
|
||||
case "enemy" -> "Enemies";
|
||||
case "collectible" -> "Collectibles & pickups";
|
||||
case "mechanism" -> "Platforms, doors & mechanisms";
|
||||
case "character" -> "Characters";
|
||||
case "creature" -> "Animals";
|
||||
case "effect" -> "Effects & ambience";
|
||||
case "checkpoint" -> "Checkpoints";
|
||||
case "exit" -> "Exits";
|
||||
case "force" -> "Forces & currents";
|
||||
case "trigger" -> "Triggers & dialogue";
|
||||
case "camera" -> "Cameras & cut scenes";
|
||||
case "player" -> "Player & start";
|
||||
default -> "Other";
|
||||
};
|
||||
}
|
||||
|
||||
private static final String[][] WORDS = {
|
||||
{
|
||||
"enemy",
|
||||
"Guard",
|
||||
"Spider",
|
||||
"Beetle",
|
||||
"Scorpion",
|
||||
"Bat",
|
||||
"Monkey",
|
||||
"Croc",
|
||||
"Snake",
|
||||
"Wasp",
|
||||
"Bee",
|
||||
"Dart",
|
||||
"Blade",
|
||||
"Boulder",
|
||||
"Gorilla",
|
||||
"Warthog",
|
||||
"Lizard",
|
||||
"Jaguar",
|
||||
"Piranha",
|
||||
"Needle",
|
||||
"Cannon",
|
||||
"Hammer"
|
||||
},
|
||||
{"collectible", "Grapes", "Banana", "Fruit", "Potion", "Idol", "Booster", "Pot", "Jar", "Box", "Pinata"},
|
||||
{
|
||||
"mechanism",
|
||||
"Platform",
|
||||
"Door",
|
||||
"Lift",
|
||||
"Bridge",
|
||||
"Switch",
|
||||
"Pad",
|
||||
"Column",
|
||||
"Statue",
|
||||
"Log",
|
||||
"Gate",
|
||||
"Lever",
|
||||
"Wall",
|
||||
"Block",
|
||||
"Treadmill",
|
||||
"MerryGoRound",
|
||||
"Rope",
|
||||
"Pole",
|
||||
"Tree",
|
||||
"Rock",
|
||||
"Wheel",
|
||||
"Puzzle",
|
||||
"Sinking",
|
||||
"Falling",
|
||||
"Rising",
|
||||
"Mouth",
|
||||
"Timer",
|
||||
"Pump",
|
||||
"Seesaw",
|
||||
"Spring",
|
||||
"Trampoline",
|
||||
"Elevator"
|
||||
},
|
||||
{
|
||||
"character",
|
||||
"Pacha",
|
||||
"Tipo",
|
||||
"Chaca",
|
||||
"Kid",
|
||||
"Yzma",
|
||||
"Kronk",
|
||||
"Bucky",
|
||||
"Villager",
|
||||
"Folk",
|
||||
"Kusco",
|
||||
"Kuzco",
|
||||
"Man",
|
||||
"Girl",
|
||||
"Naked",
|
||||
"General",
|
||||
"Herder",
|
||||
"Condor",
|
||||
"Blockhead"
|
||||
},
|
||||
{"creature", "Bird", "Bunny", "Butterfly", "Moth", "Ladybird", "Fly", "Frog", "Turtle", "Squirrel", "Wompy"},
|
||||
{
|
||||
"effect",
|
||||
"Mist",
|
||||
"Rain",
|
||||
"Water",
|
||||
"Firework",
|
||||
"Sparkle",
|
||||
"Smoke",
|
||||
"Dripping",
|
||||
"Fire",
|
||||
"Sound",
|
||||
"Steam",
|
||||
"Light"
|
||||
}
|
||||
};
|
||||
|
||||
public static String category(String name) {
|
||||
String c = StratCatalog.category(name);
|
||||
if (!c.equals("other")) {
|
||||
return c;
|
||||
}
|
||||
for (String[] w : WORDS) {
|
||||
for (int i = 1; i < w.length; i++) {
|
||||
if (name.contains(w[i])) {
|
||||
return w[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
static BufferedImage thumbnail(
|
||||
StratModels sm,
|
||||
int code,
|
||||
String name,
|
||||
List<TextChunk.TextureEntry> tex,
|
||||
TextChunk tc,
|
||||
int[] pageClass,
|
||||
List<TexturePage> pages) {
|
||||
int mi = sm.modelOf(code);
|
||||
if (mi < 0) {
|
||||
return null;
|
||||
}
|
||||
Model m = sm.models.get(mi);
|
||||
if (StratModels.skinned(m)) {
|
||||
m = sm.posed(code, mi);
|
||||
}
|
||||
if (m == null || m.faces.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
RenderMesh rm = LevelSceneAdapter.mesh(name, m, tex, tc, pageClass, RenderMesh.LIT);
|
||||
return Thumbnails.render(rm, pages, THUMB);
|
||||
}
|
||||
|
||||
/** Entries matching a query (name or level, case-insensitive), world (0 = all) and category (null = all). */
|
||||
public List<Entry> filter(String q, int world, String category, boolean importableOnly) {
|
||||
String ql = q == null ? "" : q.trim().toLowerCase(Locale.ROOT);
|
||||
List<Entry> out = new ArrayList<>();
|
||||
for (Entry e : entries) {
|
||||
if (world > 0 && e.world != world) {
|
||||
continue;
|
||||
}
|
||||
if (category != null && !category.equals(e.category)) {
|
||||
continue;
|
||||
}
|
||||
if (importableOnly && !e.importable) {
|
||||
continue;
|
||||
}
|
||||
if (!ql.isEmpty()
|
||||
&& !e.type.toLowerCase(Locale.ROOT).contains(ql)
|
||||
&& !e.level.contains(ql)
|
||||
&& !e.levelName.toLowerCase(Locale.ROOT).contains(ql)) {
|
||||
continue;
|
||||
}
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import java.util.List;
|
||||
|
||||
/** Lighting presets for the bake tool, plus the fog/ambient colours that go with them. */
|
||||
public final class BakePresets {
|
||||
private BakePresets() {}
|
||||
|
||||
/**
|
||||
* @param fogBgr zone fog/backdrop colour (0x00BBGGRR)
|
||||
* @param ambientBgr zone ambient colour for objects (0x00BBGGRR)
|
||||
*/
|
||||
public record Preset(String name, VertexBake.Rig rig, int fogBgr, int ambientBgr) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private static double[] c(double r, double g, double b) {
|
||||
return new double[] {r, g, b};
|
||||
}
|
||||
|
||||
private static int bgr(int r, int g, int b) {
|
||||
return r | g << 8 | b << 16;
|
||||
}
|
||||
|
||||
public static final List<Preset> ALL = List.of(
|
||||
new Preset(
|
||||
"Day",
|
||||
new VertexBake.Rig(
|
||||
215,
|
||||
50,
|
||||
0.62,
|
||||
c(1.0, 0.93, 0.80),
|
||||
c(0.50, 0.55, 0.66),
|
||||
c(0.30, 0.28, 0.24),
|
||||
0.55,
|
||||
1.3,
|
||||
0.35,
|
||||
false),
|
||||
bgr(0xa8, 0xc8, 0xe8),
|
||||
bgr(0x40, 0x40, 0x40)),
|
||||
new Preset(
|
||||
"Dusk",
|
||||
new VertexBake.Rig(
|
||||
255,
|
||||
14,
|
||||
0.70,
|
||||
c(1.0, 0.58, 0.32),
|
||||
c(0.36, 0.30, 0.44),
|
||||
c(0.22, 0.16, 0.15),
|
||||
0.55,
|
||||
1.3,
|
||||
0.30,
|
||||
true),
|
||||
bgr(0xc0, 0x70, 0x50),
|
||||
bgr(0x40, 0x30, 0x30)),
|
||||
new Preset(
|
||||
"Night",
|
||||
new VertexBake.Rig(
|
||||
140,
|
||||
45,
|
||||
0.28,
|
||||
c(0.55, 0.65, 1.0),
|
||||
c(0.13, 0.16, 0.30),
|
||||
c(0.06, 0.06, 0.10),
|
||||
0.6,
|
||||
1.3,
|
||||
0.25,
|
||||
true),
|
||||
bgr(0x08, 0x0c, 0x24),
|
||||
bgr(0x18, 0x1c, 0x30)),
|
||||
new Preset(
|
||||
"Cave",
|
||||
new VertexBake.Rig(
|
||||
0, 80, 0.0, c(1, 1, 1), c(0.34, 0.30, 0.25), c(0.20, 0.17, 0.14), 0.85, 2.0, 0.0, true),
|
||||
bgr(0x10, 0x0c, 0x08),
|
||||
bgr(0x30, 0x2c, 0x28)),
|
||||
new Preset(
|
||||
"Flat (no lighting)",
|
||||
new VertexBake.Rig(0, 90, 0.0, c(1, 1, 1), c(1, 1, 1), c(1, 1, 1), 0.0, 1.0, 1.0, false),
|
||||
bgr(0xa8, 0xc8, 0xe8),
|
||||
bgr(0x40, 0x40, 0x40)));
|
||||
|
||||
/** The level's own sun (LevelSettings "Lighting" group, used for brushes). */
|
||||
public static VertexBake.Rig fromSettings(LevelSettings s, boolean points) {
|
||||
return new VertexBake.Rig(
|
||||
s.sunAzimuth(),
|
||||
s.sunElevation(),
|
||||
s.sunStrength(),
|
||||
s.sunColor(),
|
||||
s.sky(),
|
||||
s.ground(),
|
||||
s.ao(),
|
||||
s.aoRadius(),
|
||||
s.shadow(),
|
||||
points);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.SceneEvent;
|
||||
import com.eliasmueller.llamamod.editor.model.SceneListener;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import java.awt.Color;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.DoubleFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.ToDoubleFunction;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/**
|
||||
* Binds form widgets to node properties: edits go through undoable {@link SetProp}s (one merge key
|
||||
* per widget, so repeated spinner clicks are one undo step) and the widgets follow scene changes
|
||||
* (undo, gizmo drags) while the form is shown.
|
||||
*/
|
||||
final class Binder {
|
||||
private final EditorContext ctx;
|
||||
private final Node node;
|
||||
private final List<Runnable> refreshers = new ArrayList<>();
|
||||
private boolean updating;
|
||||
private final SceneListener listener;
|
||||
private boolean alive = true;
|
||||
|
||||
Binder(EditorContext ctx, Node node) {
|
||||
this.ctx = ctx;
|
||||
this.node = node;
|
||||
listener = this::changed;
|
||||
ctx.addSceneListener(listener);
|
||||
}
|
||||
|
||||
private void changed(SceneEvent e) {
|
||||
if (!alive || e.node() != node || updating) {
|
||||
return;
|
||||
}
|
||||
SwingUtilities.invokeLater(this::refresh);
|
||||
}
|
||||
|
||||
/** Stops following the scene (the shell rebuilds sections on every selection change). */
|
||||
void attach(JComponent root) {
|
||||
boolean[] shown = {false};
|
||||
root.addHierarchyListener(e -> {
|
||||
if (root.isDisplayable()) {
|
||||
shown[0] = true;
|
||||
} else if (shown[0]) {
|
||||
alive = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
updating = true;
|
||||
try {
|
||||
for (Runnable r : refreshers) {
|
||||
r.run();
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onRefresh(Runnable r) {
|
||||
refreshers.add(r);
|
||||
}
|
||||
|
||||
/** Runs r now and on every refresh. */
|
||||
void live(Runnable r) {
|
||||
r.run();
|
||||
onRefresh(r);
|
||||
}
|
||||
|
||||
/** Label whose text follows the node. */
|
||||
JLabel label(Supplier<String> text) {
|
||||
JLabel l = new JLabel();
|
||||
live(() -> l.setText(text.get()));
|
||||
return l;
|
||||
}
|
||||
|
||||
void set(String prop, Object value, Object mergeKey) {
|
||||
if (updating) {
|
||||
return;
|
||||
}
|
||||
Object old = node.get(prop);
|
||||
if (java.util.Objects.equals(old, value)) {
|
||||
return;
|
||||
}
|
||||
updating = true;
|
||||
try {
|
||||
ctx.execute(SetProp.of(node, node.prop(prop), value, mergeKey));
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
SwingUtilities.invokeLater(this::refresh);
|
||||
}
|
||||
|
||||
/** Spinner showing f(node) in display units; writes toProp(display value). */
|
||||
JSpinner number(
|
||||
double min,
|
||||
double max,
|
||||
double step,
|
||||
String pattern,
|
||||
ToDoubleFunction<Node> get,
|
||||
DoubleFunction<Object> toProp,
|
||||
String prop) {
|
||||
Object key = new Object();
|
||||
JSpinner[] sp = new JSpinner[1];
|
||||
sp[0] = Ui.number(get.applyAsDouble(node), min, max, step, pattern, v -> {
|
||||
Object pv = toProp.apply(v);
|
||||
if (!updating && pv != null) {
|
||||
set(prop, pv, key);
|
||||
}
|
||||
});
|
||||
onRefresh(() -> sp[0].setValue(Ui.clamp(get.applyAsDouble(node), min, max)));
|
||||
return sp[0];
|
||||
}
|
||||
|
||||
/** Generic custom edit: run with this binder's echo guard. */
|
||||
void edit(Runnable r) {
|
||||
if (!updating) {
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
|
||||
JCheckBox check(String label, Function<Node, Boolean> get, Consumer<Boolean> set) {
|
||||
JCheckBox c = new JCheckBox(label, get.apply(node));
|
||||
c.addActionListener(e -> {
|
||||
if (!updating) {
|
||||
set.accept(c.isSelected());
|
||||
}
|
||||
});
|
||||
onRefresh(() -> c.setSelected(get.apply(node)));
|
||||
return c;
|
||||
}
|
||||
|
||||
<T> JComboBox<T> combo(T[] items, Function<Node, Integer> index, Consumer<Integer> set) {
|
||||
JComboBox<T> c = new JComboBox<>(items);
|
||||
c.setMaximumRowCount(20);
|
||||
int i = index.apply(node);
|
||||
c.setSelectedIndex(i >= 0 && i < items.length ? i : -1);
|
||||
c.addActionListener(e -> {
|
||||
if (!updating && c.getSelectedIndex() >= 0) {
|
||||
set.accept(c.getSelectedIndex());
|
||||
}
|
||||
});
|
||||
onRefresh(() -> {
|
||||
int k = index.apply(node);
|
||||
c.setSelectedIndex(k >= 0 && k < items.length ? k : -1);
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
Ui.ColorButton color(String title, Function<Node, Color> get, java.util.function.BiConsumer<Color, Object> set) {
|
||||
Ui.ColorButton b = new Ui.ColorButton(title, () -> get.apply(node), set);
|
||||
onRefresh(b::refresh);
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Regions;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import com.eliasmueller.llamamod.editor.ui.LevelCheck;
|
||||
import com.eliasmueller.llamamod.editor.ui.Problem;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Problems checks for zones/fog, visibility regions, vertex lighting and lights. */
|
||||
final class EnvChecks implements LevelCheck {
|
||||
/** Fog end below this (cells, detail Maximum) makes the level a fog wall. */
|
||||
static final double FOG_NEAR = 3;
|
||||
|
||||
/** Above this the engine draws and wakes up a lot (the originals stay ≤ 23). */
|
||||
static final double FOG_FAR = 30;
|
||||
|
||||
@Override
|
||||
public void check(Scene s, List<Problem> out) {
|
||||
zones(s, out);
|
||||
regions(s, out);
|
||||
unlit(s, out);
|
||||
lights(s, out);
|
||||
}
|
||||
|
||||
static void zones(Scene s, List<Problem> out) {
|
||||
int w = s.settings().width(), h = s.settings().height();
|
||||
int starts = 0;
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (EnvMath.isStart(z)) {
|
||||
starts++;
|
||||
}
|
||||
}
|
||||
if (!s.zones().isEmpty() && starts == 0) {
|
||||
out.add(Problem.warning(
|
||||
"No start zone (zone flag 8): the level starts without its fog, colours and music settings",
|
||||
s.zones().getFirst()));
|
||||
} else if (starts > 1) {
|
||||
out.add(Problem.warning(starts + " zones are marked as start zone; the last one wins", null));
|
||||
}
|
||||
for (EnvZone z : s.zones()) {
|
||||
String name = EnvMath.isStart(z) ? "Start zone" : "Zone " + z.cellX() + "," + z.cellZ();
|
||||
double[] hi = EnvMath.fog(z, EnvMath.DETAIL_MAX);
|
||||
double[] lo = EnvMath.fog(z, EnvMath.DETAIL_MIN);
|
||||
if (z.fogMode() != 0 || EnvMath.isStart(z)) {
|
||||
if (hi[1] < FOG_NEAR) {
|
||||
out.add(Problem.warning(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"%s: fog ends after %.1f cells even at detail Maximum (%.1f at Minimum) – a wall of"
|
||||
+ " fog, objects pop in right in front of the player",
|
||||
name,
|
||||
hi[1],
|
||||
lo[1]),
|
||||
z));
|
||||
} else if (hi[1] > FOG_FAR) {
|
||||
out.add(Problem.warning(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"%s: draw distance %.1f cells at detail Maximum – slow, and enemies wake up from"
|
||||
+ " far away (the original levels use 7–23)",
|
||||
name,
|
||||
hi[1]),
|
||||
z));
|
||||
}
|
||||
if (hi[0] >= hi[1] || lo[0] >= lo[1]) {
|
||||
out.add(Problem.warning(name + ": fog start is not before fog end", z));
|
||||
}
|
||||
}
|
||||
if (EnvMath.isStart(z)) {
|
||||
continue;
|
||||
}
|
||||
boolean cellIn = z.cellX() >= 0 && z.cellZ() >= 0 && z.cellX() < w && z.cellZ() < h;
|
||||
if (!cellIn) {
|
||||
out.add(Problem.warning(
|
||||
name + ": trigger cell outside the " + w + "×" + h + " grid, never entered", z));
|
||||
}
|
||||
if (EnvMath.teleports(z)) {
|
||||
if (z.targetX() < 0 || z.targetZ() < 0 || z.targetX() >= w || z.targetZ() >= h) {
|
||||
out.add(Problem.error(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"%s: teleport target %.1f, %.1f is outside the grid",
|
||||
name,
|
||||
z.targetX(),
|
||||
z.targetZ()),
|
||||
z));
|
||||
}
|
||||
if (cellIn && !hasExitFloor(s, z.cellX(), z.cellZ())) {
|
||||
out.add(Problem.info(
|
||||
name + ": no exit floor (collision material 16) or door object in the trigger cell – the"
|
||||
+ " teleport only fires when the player steps on an exit floor",
|
||||
z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean hasExitFloor(Scene s, int cx, int cz) {
|
||||
for (Tile t : s.tiles()) {
|
||||
if (t.cellX() != cx || t.cellZ() != cz) {
|
||||
continue;
|
||||
}
|
||||
if (t.swapHasMaterial(16)) {
|
||||
return true;
|
||||
}
|
||||
TileModel m = t.model();
|
||||
if (m != null) {
|
||||
for (int g = 0; g < 3; g++) {
|
||||
for (int k = 0; k < m.collisionCount(g); k++) {
|
||||
if (m.collisionMaterial(g, k) == 16) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Strat st : s.strats()) {
|
||||
if ((int) Math.floor(st.gx()) == cx && (int) Math.floor(st.gz()) == cz) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return !s.brushes().isEmpty();
|
||||
}
|
||||
|
||||
static void regions(Scene s, List<Problem> out) {
|
||||
Regions r = s.regions();
|
||||
int w = s.settings().width(), h = s.settings().height();
|
||||
if (r.grid().length != w * h || r.table().length < 2 * Regions.COUNT) {
|
||||
return;
|
||||
}
|
||||
Strat p = s.playerStart();
|
||||
if (p != null) {
|
||||
int cx = (int) Math.floor(p.gx()), cz = (int) Math.floor(p.gz());
|
||||
int reg = r.regionAt(cx, cz, w);
|
||||
if (reg >= 0 && r.mask(reg) != 0 && (r.mask(reg) & (1 << reg)) == 0) {
|
||||
out.add(Problem.error(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"The player start (cell %d, %d) is in visibility region %d whose mask does not include"
|
||||
+ " region %d itself – the ground and the llama would be invisible",
|
||||
cx,
|
||||
cz,
|
||||
reg,
|
||||
reg),
|
||||
p));
|
||||
}
|
||||
}
|
||||
boolean[] used = RegionGen.used(r.grid());
|
||||
long[] tris = RegionGen.trianglesPerRegion(s, r.grid());
|
||||
int seen = 0;
|
||||
for (int a = 0; a < Regions.COUNT; a++) {
|
||||
if (used[a]) {
|
||||
seen |= r.mask(a) == 0 ? -1 : r.mask(a);
|
||||
}
|
||||
}
|
||||
for (int b = 0; b < Regions.COUNT; b++) {
|
||||
if (used[b] && tris[b] > 0 && (seen & (1 << b)) == 0) {
|
||||
out.add(Problem.warning(
|
||||
"Visibility region " + b + " (" + tris[b] + " triangles) is not drawn from any region – its"
|
||||
+ " geometry is never visible",
|
||||
r));
|
||||
}
|
||||
}
|
||||
long[] vis = RegionGen.visibleTriangles(s, r.grid(), r.table());
|
||||
for (int a = 0; a < Regions.COUNT; a++) {
|
||||
if (used[a] && vis[a] > RegionGen.HEAVY) {
|
||||
out.add(Problem.info(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"Visibility region %d draws %,d triangles (heavy for the original engine; the fog"
|
||||
+ " distance limits it further)",
|
||||
a,
|
||||
vis[a]),
|
||||
r));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void unlit(Scene s, List<Problem> out) {
|
||||
int grey = 0, black = 0;
|
||||
Tile first = null;
|
||||
for (Tile t : s.tiles()) {
|
||||
TileModel m = t.model();
|
||||
if (m == null || m.faceCount() == 0) {
|
||||
continue;
|
||||
}
|
||||
int nv = m.vertexCount();
|
||||
int[] c = t.colors();
|
||||
if (c.length < nv || nv == 0) {
|
||||
continue;
|
||||
}
|
||||
boolean allGrey = true, allBlack = true;
|
||||
for (int i = 0; i < nv; i++) {
|
||||
int v = c[i] & 0xffffff;
|
||||
allGrey &= v == 0x808080;
|
||||
allBlack &= v == 0;
|
||||
}
|
||||
if (allGrey || allBlack) {
|
||||
if (first == null) {
|
||||
first = t;
|
||||
}
|
||||
if (allGrey) {
|
||||
grey++;
|
||||
} else {
|
||||
black++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grey + black > 0) {
|
||||
out.add(Problem.warning(
|
||||
(grey > 0 ? grey + " tiles have flat grey (unlit) vertex colours" : "")
|
||||
+ (grey > 0 && black > 0 ? ", " : "")
|
||||
+ (black > 0 ? black + " tiles are completely black" : "")
|
||||
+ " – use Environment → Lighting → Bake lighting",
|
||||
first));
|
||||
}
|
||||
}
|
||||
|
||||
static void lights(Scene s, List<Problem> out) {
|
||||
for (Light l : s.lights()) {
|
||||
if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
|
||||
if (l.rangeB() <= 0) {
|
||||
out.add(Problem.warning(l.label() + ": falloff radius 0, the light has no effect", l));
|
||||
} else if (l.rangeB() < l.a()) {
|
||||
out.add(Problem.info(l.label() + ": falloff radius smaller than the full radius", l));
|
||||
}
|
||||
} else if (l.type() != Light.DIRECTIONAL) {
|
||||
out.add(Problem.info(l.label() + ": type " + l.type() + " is ignored by the engine", l));
|
||||
}
|
||||
if (l.r() == 0 && l.g() == 0 && l.b() == 0) {
|
||||
out.add(Problem.info(l.label() + ": colour is black (no effect)", l));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.render.Legend;
|
||||
import java.awt.Color;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Engine rules for zones, fog and lights (groove.exe), shared by the panels, the previews and the
|
||||
* checks. Zone colours are stored as 0x00BBGGRR, tile vertex colours as R | G<<8 | B<<16 | frames<<24
|
||||
* with 0x80 = 1.0.
|
||||
*/
|
||||
public final class EnvMath {
|
||||
private EnvMath() {}
|
||||
|
||||
// ------------------------------------------------------------ zone fields
|
||||
|
||||
public static final int F_CELL_X = 0;
|
||||
public static final int F_CELL_Y = 1;
|
||||
public static final int F_CELL_Z = 2;
|
||||
public static final int F_X = 3;
|
||||
public static final int F_Y = 4;
|
||||
public static final int F_Z = 5;
|
||||
public static final int F_ANGLE = 7;
|
||||
public static final int F_FLAGS = 8;
|
||||
public static final int F_SKY = 10;
|
||||
public static final int F_SKY_YAW = 11;
|
||||
public static final int F_MODE = 13;
|
||||
public static final int F_PUSH = 14;
|
||||
public static final int F_NEAR_LO = 15;
|
||||
public static final int F_FAR_LO = 16;
|
||||
public static final int F_NEAR_HI = 17;
|
||||
public static final int F_FAR_HI = 18;
|
||||
public static final int F_AMBIENT = 19;
|
||||
public static final int F_FOG = 20;
|
||||
public static final int F_MUSIC = 23;
|
||||
|
||||
/** flags (+0x20) bit 2: only switch environment, do not move the player (FUN_0054de00). */
|
||||
public static final int FLAG_NO_TELEPORT = 4;
|
||||
|
||||
/** flags bit 3: zone active at level start (loader FUN_0042ac50 → DAT_00584644). */
|
||||
public static final int FLAG_START = 8;
|
||||
|
||||
/** angleFlags bit 0: trigger also one cell below; bit 1: one cell above + no forward step. */
|
||||
public static final int ANGLE_Y_BELOW = 1;
|
||||
|
||||
public static final int ANGLE_Y_ABOVE = 2;
|
||||
|
||||
/** Fog modes (zone +0x34 → DAT_005865cc, FUN_00438180). */
|
||||
public static final String[] FOG_MODES = {
|
||||
"0 – no fog",
|
||||
"1 – darkness, light around the player (radius = fog start…end)",
|
||||
"2 – fog (table)",
|
||||
"3 – fog (vertex, most levels)"
|
||||
};
|
||||
|
||||
public static boolean isStart(EnvZone z) {
|
||||
return (z.field(F_FLAGS) & FLAG_START) != 0;
|
||||
}
|
||||
|
||||
public static boolean teleports(EnvZone z) {
|
||||
return (z.field(F_FLAGS) & FLAG_NO_TELEPORT) == 0 && !isStart(z);
|
||||
}
|
||||
|
||||
/** The zone the level starts with (flag 8; else the (1,1,1) default entry, else the first). */
|
||||
public static EnvZone startZone(Scene s) {
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (isStart(z)) {
|
||||
return z;
|
||||
}
|
||||
}
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (z.isDefault()) {
|
||||
return z;
|
||||
}
|
||||
}
|
||||
return s.zones().isEmpty() ? null : s.zones().getFirst();
|
||||
}
|
||||
|
||||
public static String describe(EnvZone z) {
|
||||
if (isStart(z)) {
|
||||
return "Start zone (level default)";
|
||||
}
|
||||
String cell = z.cellX() + "," + z.cellZ();
|
||||
if (!teleports(z)) {
|
||||
return "Environment " + cell;
|
||||
}
|
||||
return String.format(Locale.ROOT, "Teleport %s → %.1f, %.1f", cell, z.targetX(), z.targetZ());
|
||||
}
|
||||
|
||||
/** Facing at the target in degrees (0 = +z, 90 = +x), from bits 4..11 of the angle field. */
|
||||
public static double facingDegrees(EnvZone z) {
|
||||
return (z.field(F_ANGLE) & 0xff0) * 360.0 / 4096;
|
||||
}
|
||||
|
||||
public static int withFacing(int angleField, double degrees) {
|
||||
int a = (int) Math.round(((degrees % 360 + 360) % 360) * 4096 / 360.0) & 0xff0;
|
||||
return (angleField & ~0xff0) | a;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ fog
|
||||
|
||||
/** Detail level value t of the game's detail setting (0x5848cc). */
|
||||
public static final double DETAIL_MIN = 0;
|
||||
|
||||
public static final double DETAIL_MED = 0.5;
|
||||
public static final double DETAIL_MAX = 1;
|
||||
|
||||
/**
|
||||
* Fog start and end (cells) at detail t, as FUN_0041a480 computes them: the low pair belongs to
|
||||
* detail Minimum, the high pair to Maximum (if high start ≤ low start the loader uses 1.5 × the
|
||||
* low pair), interpolated and then scaled by t + (1 − t)·0.5.
|
||||
*/
|
||||
public static double[] fog(EnvZone z, double t) {
|
||||
double ns = z.field(F_NEAR_LO), fs = z.field(F_FAR_LO);
|
||||
double nh = z.field(F_NEAR_HI), fh = z.field(F_FAR_HI);
|
||||
if (nh <= ns) {
|
||||
nh = ns + Math.floor(ns / 2);
|
||||
fh = fs + Math.floor(fs / 2);
|
||||
}
|
||||
double k = t + (1 - t) * 0.5;
|
||||
double start = (ns + (nh - ns) * t) * k / 4096;
|
||||
double end = (fs + (fh - fs) * t) * k / 4096;
|
||||
return new double[] {start, end};
|
||||
}
|
||||
|
||||
public static Color zoneColor(int bgr) {
|
||||
return new Color(bgr & 0xff, (bgr >> 8) & 0xff, (bgr >> 16) & 0xff);
|
||||
}
|
||||
|
||||
public static int zoneBgr(Color c) {
|
||||
return c.getRed() | c.getGreen() << 8 | c.getBlue() << 16;
|
||||
}
|
||||
|
||||
/** 0x00BBGGRR → 0xRRGGBB. */
|
||||
public static int bgrToRgb(int c) {
|
||||
return (c & 0xff) << 16 | (c & 0xff00) | (c >>> 16) & 0xff;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ music
|
||||
|
||||
/**
|
||||
* Music ids: table at 0x578960 in groove.exe (20 bytes per id, name pointer + 8), played from
|
||||
* {@code music/<name>.ASF} (FUN_00546b70). 0 stops the music.
|
||||
*/
|
||||
public static final String[] MUSIC = {
|
||||
"(none)",
|
||||
"VILLAGE",
|
||||
"JUNGLE1",
|
||||
"RIVER",
|
||||
"JUNGLE2",
|
||||
"MOUNTAIN",
|
||||
"CITY",
|
||||
"CATACOMB",
|
||||
"LAB",
|
||||
"TITLE",
|
||||
"GAMEOVER",
|
||||
"TALLY",
|
||||
"TURRACE",
|
||||
"PACHASNK",
|
||||
"MOUNT2",
|
||||
"BALLOON",
|
||||
"JUNGSPID",
|
||||
"JAGCHASE",
|
||||
"RIVER",
|
||||
"RIVRACE",
|
||||
"SNKJUNG",
|
||||
"FROG",
|
||||
"KRONK",
|
||||
"KIDLLAMA",
|
||||
"TUNE8",
|
||||
"ROLLER",
|
||||
"BUNNY",
|
||||
"BLOCK",
|
||||
"TUNE5",
|
||||
"CITYPUZZ",
|
||||
"TUNE1",
|
||||
"CHACHA",
|
||||
"JJUNGLE2",
|
||||
"TUNE9",
|
||||
"TUNE6",
|
||||
"VICTORY",
|
||||
"PUZZLE2",
|
||||
"PLATFORM",
|
||||
"(none)",
|
||||
"ENDGAME"
|
||||
};
|
||||
|
||||
public static String musicLabel(int id) {
|
||||
return id >= 0 && id < MUSIC.length ? id + " – " + MUSIC[id] : id + " – (unknown)";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ lights
|
||||
|
||||
/** Light colour as shown (engine factor ×2/255, clamped for display). */
|
||||
public static Color lightColor(Light l) {
|
||||
return new Color(Math.min(255, 2 * l.r()), Math.min(255, 2 * l.g()), Math.min(255, 2 * l.b()));
|
||||
}
|
||||
|
||||
/** Engine colour factor of a light (type 4 negative). */
|
||||
public static double[] lightFactor(Light l) {
|
||||
double[] c = {2.0 * l.r() / 255, 2.0 * l.g() / 255, 2.0 * l.b() / 255};
|
||||
if (l.type() == Light.POINT_BRIGHT) {
|
||||
for (int k = 0; k < 3; k++) {
|
||||
c[k] = -(c[k] + 1) * 0.5;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Direction of a directional light (game space, towards the light), normalised. */
|
||||
public static double[] direction(Light l) {
|
||||
double x = l.x(), y = l.y(), z = l.z();
|
||||
double len = Math.sqrt(x * x + y * y + z * z);
|
||||
return len < 1e-9 ? new double[] {0, 1, 0} : new double[] {x / len, y / len, z / len};
|
||||
}
|
||||
|
||||
/** Lights the engine applies to an object at p (game space): all directional, nearest points, max 6. */
|
||||
public static List<Light> lightsFor(Scene s, double px, double py, double pz) {
|
||||
List<Light> out = new ArrayList<>();
|
||||
List<Light> points = new ArrayList<>();
|
||||
for (Light l : s.lights()) {
|
||||
if (l.type() == Light.DIRECTIONAL) {
|
||||
if (out.size() < 6) {
|
||||
out.add(l);
|
||||
}
|
||||
} else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
|
||||
points.add(l);
|
||||
}
|
||||
}
|
||||
points.sort(Comparator.comparingDouble(l -> sq(l.x() - px) + sq(l.y() - py) + sq(l.z() - pz)));
|
||||
for (Light l : points) {
|
||||
if (out.size() >= 6) {
|
||||
break;
|
||||
}
|
||||
out.add(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light at a vertex (FUN_0041ba10): ambient + Σ colour·max(0, n·L), point lights with full
|
||||
* strength up to radius a and a linear falloff to b. Game space; result 1.0 = texture colour.
|
||||
*/
|
||||
public static double[] shade(double[] ambient, List<Light> lights, double[] p, double[] n) {
|
||||
double[] c = ambient.clone();
|
||||
for (Light l : lights) {
|
||||
double f;
|
||||
if (l.type() == Light.DIRECTIONAL) {
|
||||
double[] d = direction(l);
|
||||
f = d[0] * n[0] + d[1] * n[1] + d[2] * n[2];
|
||||
} else {
|
||||
double dx = l.x() - p[0], dy = l.y() - p[1], dz = l.z() - p[2];
|
||||
double d2 = dx * dx + dy * dy + dz * dz;
|
||||
if (d2 > l.rangeB() * l.rangeB() || d2 < 1e-12) {
|
||||
continue;
|
||||
}
|
||||
double d = Math.sqrt(d2);
|
||||
double cos = (dx * n[0] + dy * n[1] + dz * n[2]) / d;
|
||||
if (cos < 0) {
|
||||
continue;
|
||||
}
|
||||
f = cos;
|
||||
if (d2 > l.a() * l.a() && l.rangeB() > l.a()) {
|
||||
f *= (l.rangeB() - d) / (l.rangeB() - l.a());
|
||||
}
|
||||
}
|
||||
if (f <= 0) {
|
||||
continue;
|
||||
}
|
||||
double[] lc = lightFactor(l);
|
||||
for (int k = 0; k < 3; k++) {
|
||||
c[k] += f * lc[k];
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Ambient of a zone for objects (FUN_0041bcf0: byte / 255). */
|
||||
public static double[] ambient(EnvZone z) {
|
||||
int a = z == null ? 0x404040 : z.field(F_AMBIENT);
|
||||
return new double[] {(a & 0xff) / 255.0, ((a >> 8) & 0xff) / 255.0, ((a >> 16) & 0xff) / 255.0};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ misc
|
||||
|
||||
/** Cells → 1/4096 fixed point (zone fields). */
|
||||
static int fixed(double cells) {
|
||||
return (int) Math.round(cells * 4096);
|
||||
}
|
||||
|
||||
/** Unit vector towards a light at azimuth/elevation (degrees; 0 = +z, 90 = +x). */
|
||||
static double[] directionFrom(double azDeg, double elDeg) {
|
||||
double az = Math.toRadians(azDeg), el = Math.toRadians(elDeg);
|
||||
return new double[] {Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az)};
|
||||
}
|
||||
|
||||
static double sq(double v) {
|
||||
return v * v;
|
||||
}
|
||||
|
||||
/** Colour of a visibility region (as in the shell's region layer). */
|
||||
public static Color regionColor(int r) {
|
||||
return new Color(Legend.regionRgb(r));
|
||||
}
|
||||
|
||||
/** Tile colour word → RGB (0x80 = 1.0 kept as stored, i.e. half brightness). */
|
||||
public static int[] unpack(int w) {
|
||||
return new int[] {w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff};
|
||||
}
|
||||
|
||||
/** RGB 0..255 (0x80 = 1.0) into a colour word, keeping byte 3 (frame count) of {@code keep}. */
|
||||
public static int pack(int r, int g, int b, int keep) {
|
||||
return clamp(r) | clamp(g) << 8 | clamp(b) << 16 | (keep & 0xff000000);
|
||||
}
|
||||
|
||||
static int clamp(int v) {
|
||||
return Math.max(0, Math.min(255, v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.SceneEvent;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.render.Marker;
|
||||
import com.eliasmueller.llamamod.editor.render.Overlay;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.RenderContributor;
|
||||
import java.awt.Color;
|
||||
|
||||
/** Viewport helpers: zone targets with facing arrows, light direction arrows and radius spheres. */
|
||||
final class EnvOverlays {
|
||||
private EnvOverlays() {}
|
||||
|
||||
static final String LAYER_ZONES = "envZoneHandles";
|
||||
static final String LAYER_LIGHTS = "envLightGizmos";
|
||||
|
||||
static int rgba(Color c, int a) {
|
||||
return c.getRed() << 24 | c.getGreen() << 16 | c.getBlue() << 8 | (a & 0xff);
|
||||
}
|
||||
|
||||
static final Color ZONE = new Color(0xE0, 0x5A, 0xA0);
|
||||
static final Color TARGET = new Color(0x30, 0xD0, 0xFF);
|
||||
|
||||
/** Circle in the plane spanned by unit vectors u, v around c (export space). */
|
||||
static void circle(Overlay o, double[] c, double r, double[] u, double[] v, int rgba) {
|
||||
int n = 40;
|
||||
double[] prev = null;
|
||||
for (int i = 0; i <= n; i++) {
|
||||
double a = 2 * Math.PI * i / n;
|
||||
double[] p = {
|
||||
c[0] + r * (Math.cos(a) * u[0] + Math.sin(a) * v[0]),
|
||||
c[1] + r * (Math.cos(a) * u[1] + Math.sin(a) * v[1]),
|
||||
c[2] + r * (Math.cos(a) * u[2] + Math.sin(a) * v[2])
|
||||
};
|
||||
if (prev != null) {
|
||||
o.line(prev, p, rgba);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal circle (export space). */
|
||||
static void ring(Overlay o, double[] c, double r, int rgba) {
|
||||
circle(o, c, r, new double[] {1, 0, 0}, new double[] {0, 0, 1}, rgba);
|
||||
}
|
||||
|
||||
static void sphere(Overlay o, double[] c, double r, int rgba) {
|
||||
ring(o, c, r, rgba);
|
||||
circle(o, c, r, new double[] {1, 0, 0}, new double[] {0, 1, 0}, rgba);
|
||||
circle(o, c, r, new double[] {0, 0, 1}, new double[] {0, 1, 0}, rgba);
|
||||
}
|
||||
|
||||
/** Arrow from a to b with a head at b (export space). */
|
||||
static void arrow(Overlay o, double[] a, double[] b, double head, int rgba) {
|
||||
o.line(a, b, rgba);
|
||||
double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
|
||||
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (len < 1e-6) {
|
||||
return;
|
||||
}
|
||||
dx /= len;
|
||||
dy /= len;
|
||||
dz /= len;
|
||||
double[] side = Math.abs(dy) > 0.9 ? new double[] {1, 0, 0} : new double[] {-dz, 0, dx};
|
||||
double sl = Math.sqrt(side[0] * side[0] + side[1] * side[1] + side[2] * side[2]);
|
||||
for (int s = -1; s <= 1; s += 2) {
|
||||
double[] h = {
|
||||
b[0] - dx * head + s * side[0] / sl * head * 0.5,
|
||||
b[1] - dy * head + s * side[1] / sl * head * 0.5,
|
||||
b[2] - dz * head + s * side[2] / sl * head * 0.5
|
||||
};
|
||||
o.line(b, h, rgba);
|
||||
}
|
||||
}
|
||||
|
||||
/** Anchor of directional light arrows: above the level centre (the shell's light markers). */
|
||||
static double[] anchor(Scene s) {
|
||||
return new double[] {s.settings().width() / 2.0, 8, -s.settings().height() / 2.0};
|
||||
}
|
||||
|
||||
static final RenderContributor ZONES = new RenderContributor() {
|
||||
@Override
|
||||
public String layer() {
|
||||
return LAYER_ZONES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean affectedBy(SceneEvent e) {
|
||||
return e.node() instanceof EnvZone || e.node() instanceof Tile && e.type() != SceneEvent.Type.CHANGED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean followsSelection() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build(EditorContext ctx, Builder b) {
|
||||
Scene s = ctx.scene();
|
||||
Overlay o = b.overlay();
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (EnvMath.isStart(z) || ctx.isHidden(z)) {
|
||||
continue;
|
||||
}
|
||||
boolean sel = ctx.selection().contains(z);
|
||||
int a = sel ? 255 : 120;
|
||||
double cx = z.cellX(), cy = z.cellY(), cz = -z.cellZ();
|
||||
if (sel) {
|
||||
int fill = rgba(ZONE, 70);
|
||||
double y0 = cy + 0.02;
|
||||
o.triangle(
|
||||
new double[] {cx, y0, cz},
|
||||
new double[] {cx + 1, y0, cz},
|
||||
new double[] {cx + 1, y0, cz - 1},
|
||||
fill);
|
||||
o.triangle(
|
||||
new double[] {cx, y0, cz},
|
||||
new double[] {cx + 1, y0, cz - 1},
|
||||
new double[] {cx, y0, cz - 1},
|
||||
fill);
|
||||
o.box(cx, cy, cz - 1, cx + 1, cy + 1, cz, rgba(ZONE, 255));
|
||||
}
|
||||
if (!EnvMath.teleports(z)) {
|
||||
continue;
|
||||
}
|
||||
double tx = z.targetX(), ty = z.targetY(), tz = -z.targetZ();
|
||||
double f = Math.toRadians(EnvMath.facingDegrees(z));
|
||||
double[] t0 = {tx, ty + 0.1, tz};
|
||||
double[] t1 = {tx + Math.sin(f) * 0.8, ty + 0.1, tz - Math.cos(f) * 0.8};
|
||||
arrow(o, t0, t1, 0.25, rgba(TARGET, a));
|
||||
ring(o, new double[] {tx, ty + 0.02, tz}, 0.3, rgba(TARGET, a));
|
||||
if (sel) {
|
||||
double[] from = {cx + 0.5, cy + 0.5, cz - 0.5};
|
||||
double[] mid = {(from[0] + tx) / 2, Math.max(from[1], ty) + 1.5, (from[2] + tz) / 2};
|
||||
double[] prev = from;
|
||||
for (int i = 1; i <= 16; i++) {
|
||||
double u = i / 16.0;
|
||||
double[] p = {
|
||||
(1 - u) * (1 - u) * from[0] + 2 * u * (1 - u) * mid[0] + u * u * tx,
|
||||
(1 - u) * (1 - u) * from[1] + 2 * u * (1 - u) * mid[1] + u * u * (ty + 0.1),
|
||||
(1 - u) * (1 - u) * from[2] + 2 * u * (1 - u) * mid[2] + u * u * tz
|
||||
};
|
||||
o.line(prev, p, rgba(TARGET, 230));
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
Marker m = b.marker(z, (float) tx, (float) ty + 0.1f, (float) tz, rgba(TARGET, 255));
|
||||
m.shape = Marker.Shape.DIAMOND;
|
||||
m.size = 0.09f;
|
||||
m.label = "target of " + z.label();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static final RenderContributor LIGHTS = new RenderContributor() {
|
||||
@Override
|
||||
public String layer() {
|
||||
return LAYER_LIGHTS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean affectedBy(SceneEvent e) {
|
||||
return e.node() instanceof Light;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean followsSelection() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build(EditorContext ctx, Builder b) {
|
||||
Scene s = ctx.scene();
|
||||
Overlay o = b.overlay();
|
||||
double[] an = anchor(s);
|
||||
for (Light l : s.lights()) {
|
||||
if (ctx.isHidden(l)) {
|
||||
continue;
|
||||
}
|
||||
boolean sel = ctx.selection().contains(l);
|
||||
Color c = EnvMath.lightColor(l);
|
||||
if (c.getRed() + c.getGreen() + c.getBlue() < 90) {
|
||||
c = new Color(200, 200, 200);
|
||||
}
|
||||
if (l.type() == Light.DIRECTIONAL) {
|
||||
double[] d = EnvMath.direction(l);
|
||||
double len = sel ? 4 : 2.5;
|
||||
double[] from = {an[0] + d[0] * len, an[1] + d[1] * len, an[2] - d[2] * len};
|
||||
arrow(o, from, an, sel ? 0.5 : 0.3, rgba(c, sel ? 255 : 170));
|
||||
if (sel) {
|
||||
arrow(
|
||||
o,
|
||||
new double[] {from[0] + 0.3, from[1], from[2]},
|
||||
new double[] {an[0] + 0.3, an[1], an[2]},
|
||||
0.4,
|
||||
rgba(c, 200));
|
||||
}
|
||||
} else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
|
||||
double[] p = {l.x(), l.y(), -l.z()};
|
||||
if (sel) {
|
||||
sphere(o, p, l.a(), rgba(c, 230));
|
||||
sphere(o, p, l.rangeB(), rgba(c, 110));
|
||||
o.line(p, new double[] {p[0], p[1] - 3, p[2]}, rgba(c, 120));
|
||||
} else {
|
||||
ring(o, p, l.rangeB(), rgba(c, 60));
|
||||
}
|
||||
if (l.type() == Light.POINT_BRIGHT) {
|
||||
circle(o, p, 0.25, new double[] {1, 0, 0}, new double[] {0, 1, 0}, rgba(Color.BLACK, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Regions;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.SceneEvent;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.render.Camera;
|
||||
import com.eliasmueller.llamamod.editor.render.RenderInstance;
|
||||
import com.eliasmueller.llamamod.editor.render.RenderScene;
|
||||
import com.eliasmueller.llamamod.editor.render.Viewport;
|
||||
import com.eliasmueller.llamamod.editor.render.ViewportListener;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
|
||||
/**
|
||||
* Per-window state of the environment tools: the live fog preview (fog of the zone the camera is
|
||||
* in), the region preview (only what the game draws for a camera in a region), the object lighting
|
||||
* preview and the viewport shading mode. Panels listen with {@link #addListener}.
|
||||
*/
|
||||
public final class EnvSession {
|
||||
private static final Map<EditorContext, EnvSession> SESSIONS = new WeakHashMap<>();
|
||||
|
||||
public enum FogSource {
|
||||
AUTO("Camera (nearest zone area)"),
|
||||
START("Start zone"),
|
||||
SELECTED("Selected zone only");
|
||||
|
||||
final String label;
|
||||
|
||||
FogSource(String l) {
|
||||
label = l;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
final EditorContext ctx;
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
boolean fogPreview;
|
||||
FogSource fogSource = FogSource.AUTO;
|
||||
double detail = EnvMath.DETAIL_MAX;
|
||||
EnvZone previewZone;
|
||||
EnvZone pinnedZone;
|
||||
double[] fogRange = {0, 0};
|
||||
/** Camera too far from its pivot: the fog preview is paused (overview). */
|
||||
boolean fogOverview;
|
||||
/** Fog of the previewed zone; null = no preview. */
|
||||
private Viewport.FogOverride fogBase;
|
||||
|
||||
/** -1 = off, -2 = follow the camera, else the region. */
|
||||
int regionPreview = -1;
|
||||
|
||||
int regionPreviewActive = -1;
|
||||
private final Set<Node> regionHidden = new HashSet<>();
|
||||
|
||||
boolean objectLighting;
|
||||
private final Map<RenderInstance, int[]> litInstances = new WeakHashMap<>();
|
||||
private boolean lightingQueued;
|
||||
private final Timer cameraTimer;
|
||||
|
||||
private EnvSession(EditorContext ctx) {
|
||||
this.ctx = ctx;
|
||||
fogPreview = ctx.prefs().getBool("env.fogPreview", true);
|
||||
detail = ctx.prefs().getDouble("env.fogDetail", EnvMath.DETAIL_MAX);
|
||||
cameraTimer = new Timer(120, e -> cameraMoved());
|
||||
cameraTimer.setRepeats(false);
|
||||
ctx.viewport().addViewportListener(new ViewportListener() {
|
||||
@Override
|
||||
public void cameraChanged(Camera c) {
|
||||
if (fogPreview && applyFog()) {
|
||||
fire();
|
||||
}
|
||||
cameraTimer.restart();
|
||||
}
|
||||
});
|
||||
ctx.addDocumentListener(d -> {
|
||||
pinnedZone = null;
|
||||
regionHidden.clear();
|
||||
regionPreview = -1;
|
||||
regionPreviewActive = -1;
|
||||
litInstances.clear();
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
updateFog();
|
||||
queueLighting();
|
||||
fire();
|
||||
});
|
||||
});
|
||||
ctx.addSceneListener(this::sceneChanged);
|
||||
ctx.selection().addListener(ch -> {
|
||||
if (ctx.selection().primary() instanceof EnvZone z) {
|
||||
pinnedZone = z;
|
||||
updateFog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static EnvSession of(EditorContext ctx) {
|
||||
synchronized (SESSIONS) {
|
||||
return SESSIONS.computeIfAbsent(ctx, EnvSession::new);
|
||||
}
|
||||
}
|
||||
|
||||
public void addListener(Runnable r) {
|
||||
listeners.add(r);
|
||||
}
|
||||
|
||||
void fire() {
|
||||
for (Runnable r : listeners) {
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void sceneChanged(SceneEvent e) {
|
||||
Object n = e.node();
|
||||
if (n instanceof EnvZone || e.type() == SceneEvent.Type.RELOADED) {
|
||||
if (pinnedZone != null && ctx.scene() != null && !ctx.scene().contains(pinnedZone)) {
|
||||
pinnedZone = null;
|
||||
}
|
||||
SwingUtilities.invokeLater(this::updateFog);
|
||||
}
|
||||
if (n instanceof Regions && regionPreviewActive >= 0) {
|
||||
int r = regionPreviewActive;
|
||||
regionPreviewActive = -1;
|
||||
SwingUtilities.invokeLater(() -> applyRegion(r));
|
||||
}
|
||||
if (objectLighting && (n instanceof Light || n instanceof Strat || n instanceof EnvZone)) {
|
||||
queueLighting();
|
||||
}
|
||||
SwingUtilities.invokeLater(this::fire);
|
||||
}
|
||||
|
||||
private void cameraMoved() {
|
||||
if (fogPreview) {
|
||||
updateFog();
|
||||
}
|
||||
if (regionPreview == -2) {
|
||||
applyRegion(cameraRegion());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ fog preview
|
||||
|
||||
public void setFogPreview(boolean on) {
|
||||
fogPreview = on;
|
||||
ctx.prefs().put("env.fogPreview", on);
|
||||
updateFog();
|
||||
fire();
|
||||
}
|
||||
|
||||
public void setFogSource(FogSource s) {
|
||||
fogSource = s;
|
||||
updateFog();
|
||||
fire();
|
||||
}
|
||||
|
||||
public void setDetail(double t) {
|
||||
detail = t;
|
||||
ctx.prefs().put("env.fogDetail", t);
|
||||
updateFog();
|
||||
fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the "player" is for the previews: the orbit pivot of the camera (the game camera sits a
|
||||
* few cells behind the llama), game space cells.
|
||||
*/
|
||||
double[] cameraGame() {
|
||||
return ToolEvent.toGame(ctx.viewport().camera().pivot());
|
||||
}
|
||||
|
||||
/** Zone whose environment the preview shows. */
|
||||
EnvZone zoneForPreview() {
|
||||
Scene s = ctx.scene();
|
||||
if (s == null || s.zones().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
EnvZone start = EnvMath.startZone(s);
|
||||
if (fogSource == FogSource.START) {
|
||||
return start;
|
||||
}
|
||||
if (pinnedZone != null && s.contains(pinnedZone) && ctx.selection().contains(pinnedZone)) {
|
||||
return pinnedZone;
|
||||
}
|
||||
if (fogSource == FogSource.SELECTED) {
|
||||
return pinnedZone != null && s.contains(pinnedZone) ? pinnedZone : start;
|
||||
}
|
||||
return nearestArea(s, cameraGame());
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone whose area contains p: the game switches the environment when the player teleports
|
||||
* through a zone, so the area of a zone is approximated by its teleport target (the start zone's
|
||||
* by the player start); the nearest one (xz) wins.
|
||||
*/
|
||||
static EnvZone nearestArea(Scene s, double[] p) {
|
||||
EnvZone best = EnvMath.startZone(s);
|
||||
Strat player = s.playerStart();
|
||||
double bd = player == null ? Double.MAX_VALUE : EnvMath.sq(player.gx() - p[0]) + EnvMath.sq(player.gz() - p[2]);
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (!EnvMath.teleports(z)) {
|
||||
continue;
|
||||
}
|
||||
double d = EnvMath.sq(z.targetX() - p[0]) + EnvMath.sq(z.targetZ() - p[2]);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = z;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void updateFog() {
|
||||
Viewport vp = ctx.viewport();
|
||||
EnvZone z = fogPreview && ctx.scene() != null ? zoneForPreview() : null;
|
||||
previewZone = z;
|
||||
if (z == null) {
|
||||
fogBase = null;
|
||||
vp.setFogOverride(null);
|
||||
fogRange = new double[] {0, 0};
|
||||
fire();
|
||||
return;
|
||||
}
|
||||
double[] f = EnvMath.fog(z, detail);
|
||||
fogRange = f;
|
||||
int mode = z.fogMode();
|
||||
int rgb = mode == 1 ? 0x000000 : EnvMath.bgrToRgb(z.fogColor());
|
||||
float amount = mode == 0 ? 0f : 1f;
|
||||
float end = (float) Math.max(f[1], 0.5);
|
||||
fogBase = new Viewport.FogOverride((float) Math.min(f[0], end - 0.01), end, rgb, amount, true, true);
|
||||
applyFog();
|
||||
fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fog override for the current camera distance: beyond max(10, 1.6 × fog end) from the pivot
|
||||
* the fog fades out over another half of that distance (radius grows, amount drops) instead of
|
||||
* switching off at once; the overview (no override) starts when the fade is complete and ends
|
||||
* again at 90 % of it (hysteresis). Returns whether the overview state changed.
|
||||
*/
|
||||
private boolean applyFog() {
|
||||
if (fogBase == null) {
|
||||
return false;
|
||||
}
|
||||
Viewport vp = ctx.viewport();
|
||||
Camera cam = vp.camera();
|
||||
double t = Math.max(10, 1.6 * fogBase.end());
|
||||
double u = cam.ortho ? 0 : Math.clamp((cam.position.distance(cam.pivot()) - t) / (0.5 * t), 0, 1);
|
||||
boolean was = fogOverview;
|
||||
fogOverview = was ? u > 0.9 : u >= 1;
|
||||
if (fogOverview) {
|
||||
vp.setFogOverride(null);
|
||||
} else {
|
||||
float grow = (float) (1 + 30 * u * u);
|
||||
vp.setFogOverride(new Viewport.FogOverride(
|
||||
fogBase.start() * grow,
|
||||
fogBase.end() * grow,
|
||||
fogBase.rgb(),
|
||||
(float) (fogBase.amount() * (1 - u)),
|
||||
true,
|
||||
true));
|
||||
}
|
||||
return was != fogOverview;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ region preview
|
||||
|
||||
int cameraRegion() {
|
||||
Scene s = ctx.scene();
|
||||
if (s == null) {
|
||||
return -1;
|
||||
}
|
||||
double[] p = cameraGame();
|
||||
return s.regions()
|
||||
.regionAt(
|
||||
(int) Math.floor(p[0]),
|
||||
(int) Math.floor(p[2]),
|
||||
s.settings().width());
|
||||
}
|
||||
|
||||
/** -1 off, -2 follow camera, 0..31 fixed region. */
|
||||
public void setRegionPreview(int r) {
|
||||
regionPreview = r;
|
||||
applyRegion(r == -2 ? cameraRegion() : r);
|
||||
fire();
|
||||
}
|
||||
|
||||
void applyRegion(int region) {
|
||||
Scene s = ctx.scene();
|
||||
if (s == null || region == regionPreviewActive) {
|
||||
return;
|
||||
}
|
||||
regionPreviewActive = region;
|
||||
Set<Node> hide = new HashSet<>();
|
||||
if (region >= 0 && regionPreview != -1) {
|
||||
int w = s.settings().width(), h = s.settings().height();
|
||||
int mask = s.regions().mask(region);
|
||||
if (mask != 0) {
|
||||
for (Tile t : s.tiles()) {
|
||||
if (!visible(s.regions(), mask, t.cellX(), t.cellZ(), w, h)) {
|
||||
hide.add(t);
|
||||
}
|
||||
}
|
||||
for (Strat st : s.strats()) {
|
||||
int cx = (int) Math.floor(st.gx()), cz = (int) Math.floor(st.gz());
|
||||
if (!visible(s.regions(), mask, cx, cz, w, h)) {
|
||||
hide.add(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
List<Node> show = new ArrayList<>();
|
||||
for (Node n : regionHidden) {
|
||||
if (!hide.contains(n) && s.contains(n)) {
|
||||
show.add(n);
|
||||
}
|
||||
}
|
||||
List<Node> add = new ArrayList<>();
|
||||
for (Node n : hide) {
|
||||
if (!regionHidden.contains(n) && !ctx.isHidden(n)) {
|
||||
add.add(n);
|
||||
}
|
||||
}
|
||||
if (!show.isEmpty()) {
|
||||
ctx.setHidden(show, false);
|
||||
}
|
||||
if (!add.isEmpty()) {
|
||||
ctx.setHidden(add, true);
|
||||
}
|
||||
regionHidden.removeAll(show);
|
||||
regionHidden.addAll(add);
|
||||
if (regionPreview == -1) {
|
||||
regionHidden.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Engine rule (FUN_0042bf40 / FUN_0041e020): cells outside the grid are drawn always. */
|
||||
static boolean visible(Regions r, int mask, int cx, int cz, int w, int h) {
|
||||
if (cx < 0 || cz < 0 || cx >= w || cz >= h) {
|
||||
return true;
|
||||
}
|
||||
int reg = r.regionAt(cx, cz, w);
|
||||
return reg < 0 || (mask & (1 << (reg & 31))) != 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ object lighting preview
|
||||
|
||||
public void setObjectLighting(boolean on) {
|
||||
objectLighting = on;
|
||||
if (!on) {
|
||||
RenderScene rs = ctx.viewport().scene();
|
||||
for (Map.Entry<RenderInstance, int[]> e : litInstances.entrySet()) {
|
||||
if (rs != null && rs.instances.contains(e.getKey())) {
|
||||
e.getKey().vertexColors = e.getValue();
|
||||
rs.changed(e.getKey());
|
||||
}
|
||||
}
|
||||
litInstances.clear();
|
||||
ctx.viewport().requestRender();
|
||||
} else {
|
||||
queueLighting();
|
||||
}
|
||||
fire();
|
||||
}
|
||||
|
||||
void queueLighting() {
|
||||
if (!objectLighting || lightingQueued) {
|
||||
return;
|
||||
}
|
||||
lightingQueued = true;
|
||||
SwingUtilities.invokeLater(() -> SwingUtilities.invokeLater(() -> {
|
||||
lightingQueued = false;
|
||||
applyObjectLighting();
|
||||
}));
|
||||
}
|
||||
|
||||
/** Colours every object model with the engine's per-vertex lighting (zone ambient + ≤ 6 lights). */
|
||||
void applyObjectLighting() {
|
||||
Scene s = ctx.scene();
|
||||
RenderScene rs = ctx.viewport().scene();
|
||||
if (!objectLighting || s == null || rs == null) {
|
||||
return;
|
||||
}
|
||||
double[] amb = EnvMath.ambient(previewZone != null ? previewZone : EnvMath.startZone(s));
|
||||
for (RenderInstance ri : List.copyOf(rs.instances)) {
|
||||
if (ri.kind != RenderInstance.Kind.OBJECT || !(ctx.nodeOf(ri.id) instanceof Strat st)) {
|
||||
continue;
|
||||
}
|
||||
litInstances.putIfAbsent(ri, ri.vertexColors);
|
||||
ri.vertexColors = ObjectLighting.colors(s, st, ri, amb);
|
||||
rs.changed(ri);
|
||||
}
|
||||
ctx.viewport().requestRender();
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.render.Viewport;
|
||||
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.Dock;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorExtensions;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorPlugin;
|
||||
import com.eliasmueller.llamamod.editor.ui.LayerSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.PanelSpec;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Environment, lighting and visibility tools: zone / teleport editing with a live fog preview,
|
||||
* lights with gizmos and an object lighting preview, the vertex lighting bake and colour brush,
|
||||
* visibility regions (paint, mask matrix, preview, auto-generate), level settings and checks.
|
||||
*/
|
||||
public final class EnvironmentEditorPlugin implements EditorPlugin {
|
||||
private static final Predicate<EditorContext> DOC = c -> c.document() != null;
|
||||
|
||||
@Override
|
||||
public void install(EditorExtensions x) {
|
||||
Ui.init();
|
||||
x.panel(new PanelSpec("environment", "Environment", "sun", Dock.LEFT, 30, true, EnvironmentPanel::new));
|
||||
x.panel(new PanelSpec("visibility", "Visibility", "region", Dock.RIGHT, 30, true, VisibilityPanel::new));
|
||||
x.tool(new ZoneTool());
|
||||
x.tool(new LightTool());
|
||||
x.tool(new VertexPaintTool());
|
||||
x.tool(new RegionPaintTool());
|
||||
x.inspector(new ZoneSection());
|
||||
x.inspector(new LightSection());
|
||||
x.layer(new LayerSpec(EnvOverlays.LAYER_ZONES, "Zone targets & facing", "zone", true, 61));
|
||||
x.layer(new LayerSpec(EnvOverlays.LAYER_LIGHTS, "Light gizmos", "light", true, 71));
|
||||
x.renderContributor(EnvOverlays.ZONES);
|
||||
x.renderContributor(EnvOverlays.LIGHTS);
|
||||
x.check(new EnvChecks());
|
||||
x.onOpen(EnvSession::of);
|
||||
|
||||
x.action(ActionSpec.of(
|
||||
"level.settings",
|
||||
"Level",
|
||||
10,
|
||||
10,
|
||||
"Level settings…",
|
||||
"settings",
|
||||
null,
|
||||
LevelSettingsDialog::show)
|
||||
.enabledWhen(DOC)
|
||||
.tip("Name, chapter, grid size, death plane, feature flags"));
|
||||
x.action(ActionSpec.of("env.bake", "Level", 30, 10, "Bake lighting…", "sun", null, c -> {
|
||||
c.showPanel("environment");
|
||||
c.status("Environment → Lighting: choose a preset and press Bake lighting");
|
||||
})
|
||||
.enabledWhen(DOC));
|
||||
x.action(ActionSpec.of(
|
||||
"env.zones",
|
||||
"Level",
|
||||
30,
|
||||
20,
|
||||
"Environment zones",
|
||||
"zone",
|
||||
null,
|
||||
c -> c.showPanel("environment"))
|
||||
.enabledWhen(DOC));
|
||||
x.action(ActionSpec.of(
|
||||
"env.visibility",
|
||||
"Level",
|
||||
30,
|
||||
30,
|
||||
"Visibility regions",
|
||||
"region",
|
||||
null,
|
||||
c -> c.showPanel("visibility"))
|
||||
.enabledWhen(DOC));
|
||||
x.action(ActionSpec.of("env.fogPreview", "View", 50, 10, "Fog preview", "fog", null, c -> {
|
||||
EnvSession s = EnvSession.of(c);
|
||||
s.setFogPreview(!s.fogPreview);
|
||||
})
|
||||
.checked(c -> EnvSession.of(c).fogPreview)
|
||||
.tip("Game fog of the zone the camera is in"));
|
||||
x.action(ActionSpec.of(
|
||||
"env.objectLighting", "View", 50, 20, "Light objects (LGHT preview)", "light", null, c -> {
|
||||
EnvSession s = EnvSession.of(c);
|
||||
s.setObjectLighting(!s.objectLighting);
|
||||
})
|
||||
.checked(c -> EnvSession.of(c).objectLighting));
|
||||
x.action(ActionSpec.of(
|
||||
"env.shadeVertex", "View", 50, 30, "Vertex colours only", "paint", "shift V", c -> c.viewport()
|
||||
.setShading(
|
||||
c.viewport().shading() == Viewport.SHADE_VERTEX_COLORS
|
||||
? Viewport.SHADE_NORMAL
|
||||
: Viewport.SHADE_VERTEX_COLORS))
|
||||
.checked(c -> c.viewport().shading() == Viewport.SHADE_VERTEX_COLORS)
|
||||
.tip("Show the baked/painted vertex colours without textures"));
|
||||
}
|
||||
}
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.SceneEvent;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
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.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.render.Viewport;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.Timer;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Left dock panel: zones (with the live fog preview), lights and the vertex lighting bake. */
|
||||
final class EnvironmentPanel extends JPanel {
|
||||
private final EditorContext ctx;
|
||||
private final EnvSession env;
|
||||
private final DefaultListModel<EnvZone> zoneModel = new DefaultListModel<>();
|
||||
private final JList<EnvZone> zoneList = new JList<>(zoneModel);
|
||||
private final DefaultListModel<Light> lightModel = new DefaultListModel<>();
|
||||
private final JList<Light> lightList = new JList<>(lightModel);
|
||||
private final JLabel fogInfo = new JLabel(" ");
|
||||
private boolean syncing;
|
||||
private final Timer rebuild;
|
||||
|
||||
EnvironmentPanel(EditorContext ctx) {
|
||||
super(new BorderLayout());
|
||||
this.ctx = ctx;
|
||||
this.env = EnvSession.of(ctx);
|
||||
JTabbedPane tabs = new JTabbedPane(JTabbedPane.TOP, JTabbedPane.SCROLL_TAB_LAYOUT);
|
||||
tabs.addTab("Zones", Icons.get("zone", 14), zonesTab());
|
||||
tabs.addTab("Lights", Icons.get("light", 14), lightsTab());
|
||||
tabs.addTab("Lighting", Icons.get("sun", 14), new BakePanel(ctx));
|
||||
add(tabs);
|
||||
rebuild = new Timer(60, e -> reload());
|
||||
rebuild.setRepeats(false);
|
||||
ctx.addDocumentListener(d -> rebuild.restart());
|
||||
ctx.addSceneListener(this::sceneChanged);
|
||||
ctx.selection().addListener(ch -> {
|
||||
if (ch.source() != this) {
|
||||
syncSelection();
|
||||
}
|
||||
});
|
||||
env.addListener(this::updateFogInfo);
|
||||
reload();
|
||||
}
|
||||
|
||||
private void sceneChanged(SceneEvent e) {
|
||||
boolean env = e.node() instanceof EnvZone || e.node() instanceof Light;
|
||||
if (env || (e.type() != SceneEvent.Type.CHANGED && e.node() == null)) {
|
||||
rebuild.restart();
|
||||
}
|
||||
}
|
||||
|
||||
private void reload() {
|
||||
syncing = true;
|
||||
try {
|
||||
zoneModel.clear();
|
||||
lightModel.clear();
|
||||
Scene s = ctx.scene();
|
||||
if (s != null) {
|
||||
EnvZone st = EnvMath.startZone(s);
|
||||
if (st != null) {
|
||||
zoneModel.addElement(st);
|
||||
}
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (z != st) {
|
||||
zoneModel.addElement(z);
|
||||
}
|
||||
}
|
||||
for (Light l : s.lights()) {
|
||||
lightModel.addElement(l);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
syncSelection();
|
||||
zoneList.repaint();
|
||||
lightList.repaint();
|
||||
updateFogInfo();
|
||||
}
|
||||
|
||||
private void syncSelection() {
|
||||
syncing = true;
|
||||
try {
|
||||
select(zoneList, zoneModel);
|
||||
select(lightList, lightModel);
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private <T extends Node> void select(JList<T> list, DefaultListModel<T> model) {
|
||||
List<Integer> idx = new ArrayList<>();
|
||||
for (int i = 0; i < model.size(); i++) {
|
||||
if (ctx.selection().contains(model.get(i))) {
|
||||
idx.add(i);
|
||||
}
|
||||
}
|
||||
list.setSelectedIndices(idx.stream().mapToInt(Integer::intValue).toArray());
|
||||
if (!idx.isEmpty()) {
|
||||
list.ensureIndexIsVisible(idx.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
/** List selection → editor selection; double-click frames it. */
|
||||
private <T extends Node> void bindList(JList<T> list) {
|
||||
list.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||
list.addListSelectionListener(e -> {
|
||||
List<T> sel = list.getSelectedValuesList();
|
||||
if (!e.getValueIsAdjusting() && !syncing && !sel.isEmpty()) {
|
||||
ctx.selection().set(sel, this);
|
||||
}
|
||||
});
|
||||
list.addMouseListener(new java.awt.event.MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(java.awt.event.MouseEvent e) {
|
||||
if (e.getClickCount() == 2) {
|
||||
ctx.focusSelection();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ zones
|
||||
|
||||
private JComponent zonesTab() {
|
||||
JPanel p = new JPanel(new BorderLayout());
|
||||
Ui.Form f = new Ui.Form();
|
||||
f.header("Fog preview");
|
||||
JCheckBox live = new JCheckBox("Live fog in the viewport", env.fogPreview);
|
||||
live.addActionListener(a -> env.setFogPreview(live.isSelected()));
|
||||
env.addListener(() -> live.setSelected(env.fogPreview));
|
||||
f.full(live);
|
||||
JComboBox<EnvSession.FogSource> src = new JComboBox<>(EnvSession.FogSource.values());
|
||||
src.setSelectedItem(env.fogSource);
|
||||
src.addActionListener(a -> env.setFogSource((EnvSession.FogSource) src.getSelectedItem()));
|
||||
f.row("Zone", src, "Which zone's fog is shown; a selected zone is always previewed");
|
||||
String[] names = {"Minimum", "Medium", "Maximum"};
|
||||
double[] vals = {EnvMath.DETAIL_MIN, EnvMath.DETAIL_MED, EnvMath.DETAIL_MAX};
|
||||
JComboBox<String> det = new JComboBox<>(names);
|
||||
det.setSelectedIndex(env.detail <= 0.25 ? 0 : env.detail < 0.75 ? 1 : 2);
|
||||
det.addActionListener(a -> env.setDetail(vals[det.getSelectedIndex()]));
|
||||
f.row("Detail", det, "The game's detail setting: fog distances are interpolated between the Min and Max pairs");
|
||||
f.full(fogInfo);
|
||||
p.add(f, BorderLayout.NORTH);
|
||||
|
||||
bindList(zoneList);
|
||||
zoneList.setCellRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean fo) {
|
||||
EnvZone z = (EnvZone) v;
|
||||
JLabel lab = (JLabel) super.getListCellRendererComponent(l, "", i, s, fo);
|
||||
String music = EnvMath.MUSIC[Math.max(0, Math.min(EnvMath.MUSIC.length - 1, z.music()))];
|
||||
double far = EnvMath.fog(z, 1)[1];
|
||||
lab.setText(String.format(
|
||||
Locale.ROOT,
|
||||
"<html><b>%s</b><br><span style='font-size:90%%'>fog %.1f cells · %s</span></html>",
|
||||
EnvMath.describe(z),
|
||||
far,
|
||||
music));
|
||||
lab.setIcon(Icons.swatch(EnvMath.zoneColor(z.fogColor()), 18, 18));
|
||||
lab.setIconTextGap(8);
|
||||
lab.setBorder(BorderFactory.createEmptyBorder(3, 4, 3, 4));
|
||||
return lab;
|
||||
}
|
||||
});
|
||||
p.add(new JScrollPane(zoneList), BorderLayout.CENTER);
|
||||
JButton tele = Widgets.button(
|
||||
"Teleport", "plus", "Add a teleport zone: click a trigger cell, then the target (Zone tool)", () -> {
|
||||
ctx.selection().clear(this);
|
||||
ctx.setTool("env.zones");
|
||||
ctx.status("Click the cell that triggers the teleport (it needs an exit floor, material 16)");
|
||||
});
|
||||
JButton envz = Widgets.button(
|
||||
"Environment",
|
||||
"plus",
|
||||
"Add a zone that only switches fog, colours and music (at the camera focus)",
|
||||
this::addEnvZone);
|
||||
JButton del = Widgets.button("", "delete", "Delete the selected zones", () -> ctx.run("edit.delete"));
|
||||
JButton frame = Widgets.button("", "focus", "Frame the selection", ctx::focusSelection);
|
||||
JPanel b = Ui.line(tele, envz, del, frame);
|
||||
b.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
|
||||
p.add(b, BorderLayout.SOUTH);
|
||||
return p;
|
||||
}
|
||||
|
||||
private void addEnvZone() {
|
||||
Scene s = ctx.scene();
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
Vector3f c = ctx.viewport().camera().pivot();
|
||||
EnvZone z = ZoneTool.create(s, (int) Math.floor(c.x), (int) Math.round(c.y), (int) Math.floor(-c.z));
|
||||
z.setRaw("flags", EnvMath.FLAG_NO_TELEPORT);
|
||||
ctx.execute(new AddNode(z));
|
||||
ctx.selection().set(z, this);
|
||||
}
|
||||
|
||||
private void updateFogInfo() {
|
||||
EnvZone z = env.previewZone;
|
||||
if (!env.fogPreview) {
|
||||
fogInfo.setText("<html><i>Fog preview off</i></html>");
|
||||
} else if (z == null) {
|
||||
fogInfo.setText("<html><i>No zone</i></html>");
|
||||
} else {
|
||||
fogInfo.setText(String.format(
|
||||
Locale.ROOT,
|
||||
"<html>%s <b>%s</b><br>fog %.1f → %.1f cells, %s</html>",
|
||||
env.fogOverview ? "Zoom in to see the fog of" : "Showing",
|
||||
EnvMath.describe(z),
|
||||
env.fogRange[0],
|
||||
env.fogRange[1],
|
||||
EnvMath.FOG_MODES[Math.max(0, Math.min(3, z.fogMode()))].substring(4)));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ lights
|
||||
|
||||
private JComponent lightsTab() {
|
||||
JPanel p = new JPanel(new BorderLayout());
|
||||
Ui.Form f = new Ui.Form();
|
||||
f.header("Object lighting");
|
||||
JCheckBox prev = new JCheckBox("Preview lights on objects", env.objectLighting);
|
||||
prev.addActionListener(a -> env.setObjectLighting(prev.isSelected()));
|
||||
env.addListener(() -> prev.setSelected(env.objectLighting));
|
||||
f.full(prev);
|
||||
f.note("The engine lights object models per vertex: zone ambient colour + all directional lights +"
|
||||
+ " the nearest point lights (6 at most). Tiles keep their baked vertex colours.");
|
||||
p.add(f, BorderLayout.NORTH);
|
||||
bindList(lightList);
|
||||
lightList.setCellRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean fo) {
|
||||
Light li = (Light) v;
|
||||
JLabel lab = (JLabel) super.getListCellRendererComponent(l, "", i, s, fo);
|
||||
String what = li.type() == Light.DIRECTIONAL
|
||||
? String.format(
|
||||
Locale.ROOT,
|
||||
"Directional · az %.0f°, el %.0f°",
|
||||
LightSection.azimuth(li),
|
||||
LightSection.elevation(li))
|
||||
: String.format(
|
||||
Locale.ROOT,
|
||||
"%s · %.1f, %.1f, %.1f · r %.1f",
|
||||
li.type() == Light.POINT_BRIGHT ? "Negative" : "Point",
|
||||
li.x(),
|
||||
li.y(),
|
||||
li.z(),
|
||||
li.rangeB());
|
||||
lab.setText(what);
|
||||
lab.setIcon(Icons.swatch(EnvMath.lightColor(li), 16, 16));
|
||||
lab.setIconTextGap(8);
|
||||
return lab;
|
||||
}
|
||||
});
|
||||
p.add(new JScrollPane(lightList), BorderLayout.CENTER);
|
||||
JButton point = Widgets.button(
|
||||
"Point", "plus", "Place point lights in the viewport (Lights tool)", () -> ctx.setTool("env.lights"));
|
||||
JButton dir = Widgets.button("Directional", "plus", "Add a directional light (sun)", () -> {
|
||||
if (ctx.scene() != null) {
|
||||
Light l = LightTool.directional(ctx.scene(), 215, 50, 110, 100, 80);
|
||||
ctx.execute(new AddNode(l));
|
||||
ctx.selection().set(l, this);
|
||||
}
|
||||
});
|
||||
JButton del = Widgets.button("", "delete", "Delete the selected lights", () -> ctx.run("edit.delete"));
|
||||
JPanel b = Ui.line(point, dir, del);
|
||||
b.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
|
||||
p.add(b, BorderLayout.SOUTH);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ lighting bake
|
||||
|
||||
/** Bake settings, presets and the vertex colour display mode. */
|
||||
static final class BakePanel extends JPanel {
|
||||
private final EditorContext ctx;
|
||||
double az = 215, el = 50, strength = 0.62, ao = 0.55, aoRadius = 1.3, shadow = 0.35;
|
||||
Color sun = new Color(255, 237, 204), sky = new Color(128, 140, 168), ground = new Color(77, 71, 61);
|
||||
int fogBgr = -1, ambBgr = -1;
|
||||
private final List<Runnable> refresh = new ArrayList<>();
|
||||
private final JRadioButton all = new JRadioButton("All tiles", true);
|
||||
private final JRadioButton sel = new JRadioButton("Selected tiles");
|
||||
private final JCheckBox points = new JCheckBox("Add point lights (LGHT) to tiles", false);
|
||||
private final JCheckBox brushes = new JCheckBox("Also light brushes (level setting)", true);
|
||||
private final JCheckBox zone = new JCheckBox("Set start zone fog & ambient colour", false);
|
||||
private final JLabel info = new JLabel(" ");
|
||||
|
||||
BakePanel(EditorContext ctx) {
|
||||
super(new BorderLayout());
|
||||
this.ctx = ctx;
|
||||
Ui.Form f = new Ui.Form();
|
||||
f.header("Preset");
|
||||
JComboBox<BakePresets.Preset> pre = new JComboBox<>(BakePresets.ALL.toArray(new BakePresets.Preset[0]));
|
||||
pre.setSelectedIndex(-1);
|
||||
pre.addActionListener(a -> {
|
||||
if (pre.getSelectedItem() instanceof BakePresets.Preset p) {
|
||||
load(p.rig());
|
||||
fogBgr = p.fogBgr();
|
||||
ambBgr = p.ambientBgr();
|
||||
points.setSelected(p.rig().pointLights());
|
||||
zone.setSelected(true);
|
||||
}
|
||||
});
|
||||
f.row("Preset", pre, "Fills in the values below (nothing changes until you bake)");
|
||||
f.full(Ui.line(Widgets.button(
|
||||
"Level's current", null, "Load the level's lighting settings (used for brushes)", () -> {
|
||||
if (ctx.scene() != null) {
|
||||
load(BakePresets.fromSettings(ctx.scene().settings(), points.isSelected()));
|
||||
}
|
||||
})));
|
||||
f.header("Sun");
|
||||
f.row("Azimuth (°)", num(0, 360, 5, () -> az, v -> az = v), "0 = from +z (south), 90 = from +x (east)");
|
||||
f.row("Elevation (°)", num(-10, 90, 5, () -> el, v -> el = v));
|
||||
f.row("Strength", num(0, 2, 0.05, () -> strength, v -> strength = v));
|
||||
f.row("Sun colour", color("Sun colour", () -> sun, c -> sun = c));
|
||||
f.header("Sky");
|
||||
f.row("Sky colour", color("Sky light", () -> sky, c -> sky = c), "Light from above (hemisphere)");
|
||||
f.row("Ground colour", color("Ground light", () -> ground, c -> ground = c), "Light from below");
|
||||
f.row("Ambient occlusion", num(0, 1, 0.05, () -> ao, v -> ao = v), "Darkening of corners and crevices");
|
||||
f.row("AO radius", num(0.2, 6, 0.1, () -> aoRadius, v -> aoRadius = v), "cells");
|
||||
f.row("Shadow light", num(0, 1, 0.05, () -> shadow, v -> shadow = v), "0 = black shadows, 1 = no shadows");
|
||||
f.header("Bake");
|
||||
ButtonGroup g = new ButtonGroup();
|
||||
g.add(all);
|
||||
g.add(sel);
|
||||
f.full(Ui.line(all, sel));
|
||||
f.full(points);
|
||||
f.full(brushes);
|
||||
f.full(zone);
|
||||
JButton bake = Widgets.button("Bake lighting", "sun", "Compute vertex colours (one undo step)", this::bake);
|
||||
bake.putClientProperty("JButton.buttonType", "default");
|
||||
f.full(Ui.line(bake));
|
||||
f.full(info);
|
||||
f.header("Display");
|
||||
JRadioButton tex = new JRadioButton("Textured", ctx.viewport().shading() == Viewport.SHADE_NORMAL);
|
||||
JRadioButton vc =
|
||||
new JRadioButton("Vertex colours only", ctx.viewport().shading() == Viewport.SHADE_VERTEX_COLORS);
|
||||
ButtonGroup dg = new ButtonGroup();
|
||||
dg.add(tex);
|
||||
dg.add(vc);
|
||||
tex.addActionListener(a -> ctx.viewport().setShading(Viewport.SHADE_NORMAL));
|
||||
vc.addActionListener(a -> ctx.viewport().setShading(Viewport.SHADE_VERTEX_COLORS));
|
||||
f.full(Ui.line(tex, vc));
|
||||
f.full(Ui.line(Widgets.button(
|
||||
"Paint vertex colours", "paint", "Vertex colour brush (V)", () -> ctx.setTool("env.vpaint"))));
|
||||
f.end();
|
||||
JScrollPane sp = new JScrollPane(f);
|
||||
sp.setBorder(null);
|
||||
sp.getVerticalScrollBar().setUnitIncrement(16);
|
||||
add(sp);
|
||||
setPreferredSize(new Dimension(260, 400));
|
||||
}
|
||||
|
||||
private JComponent num(
|
||||
double min,
|
||||
double max,
|
||||
double step,
|
||||
java.util.function.DoubleSupplier get,
|
||||
java.util.function.DoubleConsumer set) {
|
||||
javax.swing.JSpinner s = Ui.number(get.getAsDouble(), min, max, step, "0.00", set);
|
||||
refresh.add(() -> s.setValue(Ui.clamp(get.getAsDouble(), min, max)));
|
||||
return s;
|
||||
}
|
||||
|
||||
private JComponent color(
|
||||
String title, java.util.function.Supplier<Color> get, java.util.function.Consumer<Color> set) {
|
||||
Ui.ColorButton b = new Ui.ColorButton(title, get, (c, k) -> set.accept(c));
|
||||
refresh.add(b::refresh);
|
||||
return b;
|
||||
}
|
||||
|
||||
void load(VertexBake.Rig r) {
|
||||
az = r.azimuth();
|
||||
el = r.elevation();
|
||||
strength = r.strength();
|
||||
sun = col(r.sun());
|
||||
sky = col(r.sky());
|
||||
ground = col(r.ground());
|
||||
ao = r.ao();
|
||||
aoRadius = r.aoRadius();
|
||||
shadow = r.shadow();
|
||||
refresh.forEach(Runnable::run);
|
||||
}
|
||||
|
||||
static Color col(double[] c) {
|
||||
return new Color(unit(c[0]), unit(c[1]), unit(c[2]));
|
||||
}
|
||||
|
||||
private static int unit(double v) {
|
||||
return (int) Math.round(Ui.clamp(v, 0, 1) * 255);
|
||||
}
|
||||
|
||||
static double[] vec(Color c) {
|
||||
return new double[] {c.getRed() / 255.0, c.getGreen() / 255.0, c.getBlue() / 255.0};
|
||||
}
|
||||
|
||||
VertexBake.Rig rig() {
|
||||
return new VertexBake.Rig(
|
||||
az, el, strength, vec(sun), vec(sky), vec(ground), ao, aoRadius, shadow, points.isSelected());
|
||||
}
|
||||
|
||||
private void bake() {
|
||||
Scene s = ctx.scene();
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
List<Tile> tiles = new ArrayList<>();
|
||||
if (sel.isSelected()) {
|
||||
for (Node n : ctx.selection().nodes()) {
|
||||
if (n instanceof Tile t) {
|
||||
tiles.add(t);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tiles.addAll(s.tiles());
|
||||
}
|
||||
VertexBake.Rig rig = rig();
|
||||
boolean doBrushes = brushes.isSelected(), doZone = zone.isSelected();
|
||||
int fog = fogBgr, amb = ambBgr;
|
||||
long t0 = System.nanoTime();
|
||||
info.setText("Baking " + tiles.size() + " tiles…");
|
||||
ctx.runTask(
|
||||
"Baking lighting",
|
||||
p -> {
|
||||
VertexBake vb = new VertexBake(s);
|
||||
return vb.bake(s, tiles, rig, f -> p.update(f, "Baking lighting"));
|
||||
},
|
||||
res -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Tile, int[]> out = (Map<Tile, int[]>) res;
|
||||
List<Command> cmds = new ArrayList<>();
|
||||
if (!out.isEmpty()) {
|
||||
cmds.add(new PropMapCommand("Bake lighting", "colors", out, null));
|
||||
}
|
||||
if (doBrushes && !s.brushes().isEmpty()) {
|
||||
LevelSettings st = s.settings();
|
||||
cmds.add(SetProp.of(st, "sunAzimuth", rig.azimuth()));
|
||||
cmds.add(SetProp.of(st, "sunElevation", rig.elevation()));
|
||||
cmds.add(SetProp.of(st, "sunStrength", rig.strength()));
|
||||
cmds.add(SetProp.of(st, "sunColor", rig.sun()));
|
||||
cmds.add(SetProp.of(st, "sky", rig.sky()));
|
||||
cmds.add(SetProp.of(st, "ground", rig.ground()));
|
||||
cmds.add(SetProp.of(st, "ao", rig.ao()));
|
||||
cmds.add(SetProp.of(st, "aoRadius", rig.aoRadius()));
|
||||
cmds.add(SetProp.of(st, "shadow", rig.shadow()));
|
||||
}
|
||||
EnvZone z = EnvMath.startZone(s);
|
||||
if (doZone && z != null && fog >= 0) {
|
||||
cmds.add(SetProp.of(z, "fogColor", fog));
|
||||
cmds.add(SetProp.of(z, "ambientColor", amb));
|
||||
}
|
||||
if (!cmds.isEmpty()) {
|
||||
ctx.execute(new Batch("Bake lighting", cmds));
|
||||
}
|
||||
info.setText(String.format(
|
||||
Locale.ROOT, "Baked %d tiles in %.1f s", out.size(), (System.nanoTime() - t0) / 1e9));
|
||||
ctx.status(info.getText());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.io.ProjectIO;
|
||||
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.BrushKind;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Regions;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
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.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
|
||||
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
|
||||
final class LevelSettingsDialog {
|
||||
private LevelSettingsDialog() {}
|
||||
|
||||
/** WFPC bits: mask, label, changes the file layout (not editable). */
|
||||
record Flag(int mask, String label, boolean layout) {}
|
||||
|
||||
static final List<Flag> FLAGS = List.of(
|
||||
new Flag(0x10, "MAP has a path buffer size (block 14)", true),
|
||||
new Flag(0x100, "STPC has an extra model block", true),
|
||||
new Flag(0x800, "Fog flag of the renderer (FUN_00424b90, face flag 0x4000 handling)", false),
|
||||
new Flag(0x10000, "Tile swaps (doors / bridges, stMapreplace)", true),
|
||||
new Flag(0x80000, "Script flag (strat opcode 0x8e reads it)", false),
|
||||
new Flag(0x100000, "TRPS chunk has extra entries", true),
|
||||
new Flag(0x200000, "MAP has a path block", true),
|
||||
new Flag(0x4000000, "Path entries with an extra word", true),
|
||||
new Flag(0x8000000, "Collision materials 17/18 are ignored", false),
|
||||
new Flag(0x10000000, "Two vertex colour pointer tables (light states)", true));
|
||||
|
||||
static void show(EditorContext ctx) {
|
||||
Scene s = ctx.scene();
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
LevelSettings st = s.settings();
|
||||
JDialog d = new JDialog(ctx.frame(), "Level settings", true);
|
||||
Ui.Form f = new Ui.Form();
|
||||
f.header("Level");
|
||||
JTextField name = new JTextField(st.levelName(), 24);
|
||||
f.row("Name", name, "Shown in the level list / loading screen (NAME chunk)");
|
||||
JTextField author = new JTextField(st.author());
|
||||
f.row("Author", author);
|
||||
JTextArea desc = new JTextArea(st.description(), 3, 24);
|
||||
desc.setLineWrap(true);
|
||||
desc.setWrapStyleWord(true);
|
||||
f.row("Description", new JScrollPane(desc));
|
||||
f.row("Project", new JLabel(st.isNewLevel() ? "new level" : "changes to an original level"));
|
||||
f.row(
|
||||
"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 -> {});
|
||||
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
|
||||
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
|
||||
f.full(used);
|
||||
chapter.setEnabled(st.isNewLevel());
|
||||
|
||||
f.header("Grid");
|
||||
int w0 = st.width(), h0 = st.height();
|
||||
int[] need = extent(s);
|
||||
JSpinner gw = Ui.number(w0, Math.max(1, need[0]), 1024, 1, "0", v -> {});
|
||||
JSpinner gh = Ui.number(h0, Math.max(1, need[1]), 1024, 1, "0", v -> {});
|
||||
f.row("Width (cells, x)", gw, "Columns; growing adds cells in the east");
|
||||
f.row("Height (cells, z)", gh, "Rows; growing adds cells at the far end (higher z)");
|
||||
f.note(String.format(
|
||||
Locale.ROOT,
|
||||
"Current %d × %d. Content needs at least %d × %d. New cells get region 0.",
|
||||
w0,
|
||||
h0,
|
||||
need[0],
|
||||
need[1]));
|
||||
|
||||
f.header("Death plane");
|
||||
List<Node> kill = killPlanes(s);
|
||||
double ky = killHeight(s, kill);
|
||||
JSpinner killY = Ui.number(Double.isNaN(ky) ? -1.5 : ky, -64, 256, 0.25, "0.00", v -> {});
|
||||
killY.setEnabled(!kill.isEmpty());
|
||||
f.row("Height", killY, "Falling below / touching it kills the player (collision material 30)");
|
||||
JCheckBox addKill = new JCheckBox("Add a death plane over the whole grid", false);
|
||||
addKill.addActionListener(e -> killY.setEnabled(addKill.isSelected()));
|
||||
if (kill.isEmpty()) {
|
||||
f.full(addKill);
|
||||
f.note("No death plane in this level.");
|
||||
} else {
|
||||
f.note(kill.size() + " death plane(s): " + summary(kill));
|
||||
}
|
||||
|
||||
f.header("Feature flags (WFPC 0x" + Integer.toHexString(st.wfpc()) + ")");
|
||||
Map<Integer, JCheckBox> boxes = new LinkedHashMap<>();
|
||||
JPanel grid = new JPanel(new GridLayout(0, 1));
|
||||
int known = 0;
|
||||
for (Flag fl : FLAGS) {
|
||||
known |= fl.mask();
|
||||
JCheckBox cb = new JCheckBox(
|
||||
String.format(Locale.ROOT, "0x%08X %s", fl.mask(), fl.label()), (st.wfpc() & fl.mask()) != 0);
|
||||
cb.setEnabled(!fl.layout());
|
||||
if (fl.layout()) {
|
||||
cb.setToolTipText("Changes the layout of the level data – fixed by the base level");
|
||||
}
|
||||
boxes.put(fl.mask(), cb);
|
||||
grid.add(cb);
|
||||
}
|
||||
for (int b = 0; b < 32; b++) {
|
||||
int m = 1 << b;
|
||||
if ((known & m) == 0 && (st.wfpc() & m) != 0) {
|
||||
JCheckBox cb = new JCheckBox(String.format(Locale.ROOT, "0x%08X (unknown)", m), true);
|
||||
cb.setToolTipText("Meaning unknown; keep it unless you know better");
|
||||
boxes.put(m, cb);
|
||||
grid.add(cb);
|
||||
}
|
||||
}
|
||||
f.full(grid);
|
||||
|
||||
f.header("Objects");
|
||||
JSpinner pool = Ui.number(st.poolExtra(), 0, 4096, 1, "0", v -> {});
|
||||
f.row("Strat pool extra", pool, "Slots for objects = this + 30 (also for spawned objects)");
|
||||
f.end();
|
||||
|
||||
JButton ok = new JButton("OK");
|
||||
JButton cancel = new JButton("Cancel");
|
||||
ok.putClientProperty("JButton.buttonType", "default");
|
||||
d.getRootPane().setDefaultButton(ok);
|
||||
cancel.addActionListener(a -> d.dispose());
|
||||
ok.addActionListener(a -> {
|
||||
List<Command> c = new ArrayList<>();
|
||||
put(c, st, "name", name.getText().trim());
|
||||
put(c, st, "author", author.getText().trim());
|
||||
put(c, st, "description", desc.getText().trim());
|
||||
if (st.isNewLevel()) {
|
||||
put(c, st, "targetChapter", ((Number) chapter.getValue()).intValue());
|
||||
}
|
||||
int wfpc = st.wfpc();
|
||||
for (Map.Entry<Integer, JCheckBox> e : boxes.entrySet()) {
|
||||
wfpc = e.getValue().isSelected() ? wfpc | e.getKey() : wfpc & ~e.getKey();
|
||||
}
|
||||
put(c, st, "wfpc", wfpc);
|
||||
put(c, st, "poolExtra", ((Number) pool.getValue()).intValue());
|
||||
int nw = ((Number) gw.getValue()).intValue(), nh = ((Number) gh.getValue()).intValue();
|
||||
if (nw != w0 || nh != h0) {
|
||||
c.addAll(resize(s, nw, nh));
|
||||
}
|
||||
try {
|
||||
killY.commitEdit();
|
||||
} catch (java.text.ParseException ignored) {
|
||||
// keep the last valid value
|
||||
}
|
||||
double y = ((Number) killY.getValue()).doubleValue();
|
||||
if (!kill.isEmpty() && Math.abs(y - ky) > 1e-9) {
|
||||
c.addAll(moveKill(kill, y - ky));
|
||||
}
|
||||
if (kill.isEmpty() && addKill.isSelected()) {
|
||||
Brush b = (Brush) s.createBrush(BrushKind.KILL_PLANE);
|
||||
b.setRaw("x1", (double) nw);
|
||||
b.setRaw("z1", (double) nh);
|
||||
b.setRaw("y", y);
|
||||
c.add(new AddNode(b));
|
||||
}
|
||||
if (!c.isEmpty()) {
|
||||
ctx.execute(new Batch("Level settings", c));
|
||||
}
|
||||
d.dispose();
|
||||
});
|
||||
JPanel buttons = Ui.line(ok, cancel);
|
||||
buttons.setLayout(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT));
|
||||
JScrollPane sp = new JScrollPane(f);
|
||||
sp.setBorder(BorderFactory.createEmptyBorder());
|
||||
sp.getVerticalScrollBar().setUnitIncrement(16);
|
||||
JPanel root = new JPanel(new BorderLayout());
|
||||
root.add(sp);
|
||||
root.add(buttons, BorderLayout.SOUTH);
|
||||
d.setContentPane(root);
|
||||
d.setSize(new Dimension(560, 720));
|
||||
d.setLocationRelativeTo(ctx.frame());
|
||||
d.setVisible(true);
|
||||
}
|
||||
|
||||
private static void put(List<Command> c, Node n, String prop, Object v) {
|
||||
if (!java.util.Objects.equals(n.get(prop), v)) {
|
||||
c.add(SetProp.of(n, prop, v));
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum grid size {w, h} for the tiles and objects of the level. */
|
||||
static int[] extent(Scene s) {
|
||||
int w = 1, h = 1;
|
||||
for (Tile t : s.tiles()) {
|
||||
w = Math.max(w, t.cellX() + 1);
|
||||
h = Math.max(h, t.cellZ() + 1);
|
||||
}
|
||||
for (Strat st : s.strats()) {
|
||||
w = Math.max(w, (int) Math.floor(st.gx()) + 1);
|
||||
h = Math.max(h, (int) Math.floor(st.gz()) + 1);
|
||||
}
|
||||
for (Brush b : s.brushes()) {
|
||||
double[] bb = b.bounds();
|
||||
if (bb != null && b.brushKind() != BrushKind.KILL_PLANE) {
|
||||
w = Math.max(w, (int) Math.ceil(bb[3]));
|
||||
h = Math.max(h, (int) Math.ceil(bb[5]));
|
||||
}
|
||||
}
|
||||
return new int[] {w, h};
|
||||
}
|
||||
|
||||
/** Commands that change the grid size (region grid rows copied, new cells region 0). */
|
||||
static List<Command> resize(Scene s, int nw, int nh) {
|
||||
LevelSettings st = s.settings();
|
||||
int w = st.width(), h = st.height();
|
||||
int[] old = s.regions().grid();
|
||||
int[] g = new int[nw * nh];
|
||||
for (int z = 0; z < Math.min(h, nh); z++) {
|
||||
for (int x = 0; x < Math.min(w, nw); x++) {
|
||||
if (x + z * w < old.length) {
|
||||
g[x + z * nw] = old[x + z * w];
|
||||
}
|
||||
}
|
||||
}
|
||||
Regions rg = s.regions();
|
||||
return List.of(SetProp.of(st, "width", nw), SetProp.of(st, "height", nh), SetProp.of(rg, "grid", g));
|
||||
}
|
||||
|
||||
/** Death plane brushes (KILL_PLANE, SEA with a death plane) and tiles whose model only has material 30. */
|
||||
static List<Node> killPlanes(Scene s) {
|
||||
List<Node> out = new ArrayList<>();
|
||||
for (Brush b : s.brushes()) {
|
||||
if (b.brushKind() == BrushKind.KILL_PLANE
|
||||
|| b.brushKind() == BrushKind.SEA && !"none".equals(b.s("kill"))) {
|
||||
out.add(b);
|
||||
}
|
||||
}
|
||||
for (Tile t : s.tiles()) {
|
||||
if (isKillModel(t.model())) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static boolean isKillModel(TileModel m) {
|
||||
if (m == null || m.faceCount() > 0 || m.collisionCount() == 0) {
|
||||
return false;
|
||||
}
|
||||
for (int g = 0; g < 3; g++) {
|
||||
for (int k = 0; k < m.collisionCount(g); k++) {
|
||||
if (m.collisionMaterial(g, k) != 30) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Height of the first death plane (tiles: tile y + the model's collision height). */
|
||||
static double killHeight(Scene s, List<Node> kill) {
|
||||
for (Node n : kill) {
|
||||
if (n instanceof Brush b) {
|
||||
return b.d("y");
|
||||
}
|
||||
if (n instanceof Tile t) {
|
||||
return t.y() + collisionY(t.model());
|
||||
}
|
||||
}
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
static double collisionY(TileModel m) {
|
||||
double[] v = m.vertices();
|
||||
return m.vertexCount() > 0 ? v[1] : 0;
|
||||
}
|
||||
|
||||
static List<Command> moveKill(List<Node> kill, double dy) {
|
||||
List<Command> c = new ArrayList<>();
|
||||
for (Node n : kill) {
|
||||
if (n instanceof Brush b) {
|
||||
c.add(SetProp.of(b, "y", b.d("y") + dy));
|
||||
} else if (n instanceof Tile t) {
|
||||
c.add(SetProp.of(t, "y", t.y() + dy));
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private static String summary(List<Node> kill) {
|
||||
int b = 0, t = 0;
|
||||
for (Node n : kill) {
|
||||
if (n instanceof Brush) {
|
||||
b++;
|
||||
} else {
|
||||
t++;
|
||||
}
|
||||
}
|
||||
return (b > 0 ? b + " brush" + (b > 1 ? "es" : "") : "") + (b > 0 && t > 0 ? ", " : "")
|
||||
+ (t > 0 ? t + " no-floor tile" + (t > 1 ? "s" : "") : "") + " (all move together)";
|
||||
}
|
||||
|
||||
private static String usedChapters(EditorContext ctx, int own) {
|
||||
TreeMap<Integer, String> used = new TreeMap<>();
|
||||
try {
|
||||
for (Path p : LevelProjects.list(ctx.env().levelsDir())) {
|
||||
try {
|
||||
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
|
||||
if (i.isNewLevel()
|
||||
&& (ctx.document() == null
|
||||
|| !p.equals(ctx.document().file()))
|
||||
&& i.targetChapter() > 0) {
|
||||
used.put(i.targetChapter(), i.name());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// skip broken files
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
return " ";
|
||||
}
|
||||
if (used.isEmpty()) {
|
||||
return "<html><i>No other level project uses a chapter.</i></html>";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("<html>Used by other projects: ");
|
||||
used.forEach((k, v) -> sb.append(k == own ? "<b><font color='#d9603f'>" : "")
|
||||
.append(k)
|
||||
.append(" (")
|
||||
.append(v)
|
||||
.append(")")
|
||||
.append(k == own ? "</font></b>" : "")
|
||||
.append(" "));
|
||||
return sb.append("</html>").toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
|
||||
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.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import java.awt.Color;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.swing.JComponent;
|
||||
|
||||
/** Inspector content for a light: type, colour + intensity, direction or position and radii. */
|
||||
final class LightSection implements InspectorSection {
|
||||
static final String[] TYPES = {"Directional (sun)", "Point light", "Negative point light (darkens)"};
|
||||
|
||||
@Override
|
||||
public String title() {
|
||||
return "Light";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int order() {
|
||||
return 20;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean appliesTo(List<Node> selection) {
|
||||
return selection.size() == 1 && selection.getFirst() instanceof Light;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JComponent create(EditorContext ctx, List<Node> selection) {
|
||||
return build(ctx, (Light) selection.getFirst());
|
||||
}
|
||||
|
||||
static int typeIndex(Light l) {
|
||||
return l.type() == Light.DIRECTIONAL ? 0 : l.type() == Light.POINT_BRIGHT ? 2 : 1;
|
||||
}
|
||||
|
||||
/** Intensity = brightest channel (128 = 1.0 in the engine, i.e. ×2/255). */
|
||||
static double intensity(Light l) {
|
||||
return Math.max(l.r(), Math.max(l.g(), l.b())) / 127.5;
|
||||
}
|
||||
|
||||
static Color hue(Light l) {
|
||||
int m = Math.max(1, Math.max(l.r(), Math.max(l.g(), l.b())));
|
||||
return new Color(l.r() * 255 / m, l.g() * 255 / m, l.b() * 255 / m);
|
||||
}
|
||||
|
||||
static void setColor(EditorContext ctx, Light l, Color hue, double intensity, Object key) {
|
||||
double k = intensity * 127.5 / 255;
|
||||
int r = EnvMath.clamp((int) Math.round(hue.getRed() * k));
|
||||
int g = EnvMath.clamp((int) Math.round(hue.getGreen() * k));
|
||||
int b = EnvMath.clamp((int) Math.round(hue.getBlue() * k));
|
||||
List<Command> c = List.of(
|
||||
SetProp.of(l, l.prop("r"), r, key),
|
||||
SetProp.of(l, l.prop("g"), g, key),
|
||||
SetProp.of(l, l.prop("b"), b, key));
|
||||
ctx.execute(new Batch("Light colour", c, key));
|
||||
}
|
||||
|
||||
static JComponent build(EditorContext ctx, Light l) {
|
||||
Binder b = new Binder(ctx, l);
|
||||
Ui.Form f = new Ui.Form();
|
||||
b.attach(f);
|
||||
f.full(b.label(l::label));
|
||||
f.full(Ui.line(
|
||||
Widgets.button("Frame", "focus", "Show the light", ctx::focusSelection),
|
||||
Widgets.button("Duplicate", "duplicate", null, () -> ctx.run("edit.duplicate"))));
|
||||
f.header("Light");
|
||||
f.row("Type", b.combo(TYPES, n -> typeIndex(l), i -> {
|
||||
int t = i == 0 ? Light.DIRECTIONAL : i == 2 ? Light.POINT_BRIGHT : Light.POINT;
|
||||
List<Command> c = new ArrayList<>();
|
||||
c.add(SetProp.of(l, "type", t));
|
||||
if (t != Light.DIRECTIONAL && l.rangeB() <= 0) {
|
||||
c.add(SetProp.of(l, "a", 1.0));
|
||||
c.add(SetProp.of(l, "rangeB", 3.0));
|
||||
}
|
||||
if (t == Light.DIRECTIONAL && l.y() == 0 && l.x() == 0 && l.z() == 0) {
|
||||
c.add(SetProp.of(l, "y", 1.0));
|
||||
}
|
||||
ctx.execute(new Batch("Light type", c));
|
||||
javax.swing.SwingUtilities.invokeLater(() -> {
|
||||
ctx.selection().clear(LightSection.class);
|
||||
ctx.selection().set(l, LightSection.class);
|
||||
});
|
||||
}));
|
||||
Object ck = new Object();
|
||||
f.row("Colour", b.color("Light colour", n -> hue(l), (c, key) -> setColor(ctx, l, c, intensity(l), key)));
|
||||
javax.swing.JSpinner inten = b.number(0, 2, 0.05, "0.00", n -> intensity(l), v -> null, "r");
|
||||
inten.addChangeListener(
|
||||
e -> b.edit(() -> setColor(ctx, l, hue(l), ((Number) inten.getValue()).doubleValue(), ck)));
|
||||
f.row("Intensity", inten, "1.0 = colour value 128 (engine factor ×2/255); 2.0 = maximum");
|
||||
f.row("Red / green / blue", b.label(() -> l.r() + " / " + l.g() + " / " + l.b()));
|
||||
if (l.type() == Light.DIRECTIONAL) {
|
||||
f.header("Direction");
|
||||
f.note("Lights object models from this direction (towards the light). Tiles are not lit by"
|
||||
+ " lights – bake their vertex colours in the Environment panel.");
|
||||
javax.swing.JSpinner az = b.number(-360, 360, 5, "0.#", n -> azimuth(l), v -> null, "x");
|
||||
javax.swing.JSpinner el = b.number(-90, 90, 5, "0.#", n -> elevation(l), v -> null, "y");
|
||||
f.row("Azimuth (°)", az, "0 = from +z, 90 = from +x (east)");
|
||||
f.row("Elevation (°)", el);
|
||||
Object dk = new Object();
|
||||
javax.swing.event.ChangeListener dir = e -> b.edit(() -> setDirection(
|
||||
ctx, l, ((Number) az.getValue()).doubleValue(), ((Number) el.getValue()).doubleValue(), dk));
|
||||
az.addChangeListener(dir);
|
||||
el.addChangeListener(dir);
|
||||
} else {
|
||||
f.header("Position & range");
|
||||
f.row("X", b.number(-64, 4096, 0.25, "0.00", n -> l.x(), v -> v, "x"));
|
||||
f.row("Y (height)", b.number(-64, 256, 0.25, "0.00", n -> l.y(), v -> v, "y"));
|
||||
f.row("Z (row)", b.number(-64, 4096, 0.25, "0.00", n -> l.z(), v -> v, "z"));
|
||||
f.row(
|
||||
"Full radius",
|
||||
b.number(0, 64, 0.1, "0.00", n -> l.a(), v -> v, "a"),
|
||||
"Full strength up to this distance (cells)");
|
||||
f.row(
|
||||
"Falloff radius",
|
||||
b.number(0, 64, 0.1, "0.00", n -> l.rangeB(), v -> v, "rangeB"),
|
||||
"Linear falloff to zero at this distance; nothing beyond");
|
||||
f.row(
|
||||
"",
|
||||
b.check("Flicker", n -> l.c() == 1, on -> b.set("c", on ? 1 : 0, null)),
|
||||
"c = 1: the engine randomly scales the light ×1.5 / ×0.5 every frame (DAT_005865d4)");
|
||||
f.note("Each object is lit by all directional lights and its nearest point lights, 6 at most.");
|
||||
}
|
||||
f.end();
|
||||
return f;
|
||||
}
|
||||
|
||||
static double azimuth(Light l) {
|
||||
double[] d = EnvMath.direction(l);
|
||||
return Math.toDegrees(Math.atan2(d[0], d[2]));
|
||||
}
|
||||
|
||||
static double elevation(Light l) {
|
||||
double[] d = EnvMath.direction(l);
|
||||
return Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, d[1]))));
|
||||
}
|
||||
|
||||
static void setDirection(EditorContext ctx, Light l, double azDeg, double elDeg, Object key) {
|
||||
double[] d = EnvMath.directionFrom(azDeg, elDeg);
|
||||
List<Command> c = List.of(
|
||||
SetProp.of(l, l.prop("x"), d[0], key),
|
||||
SetProp.of(l, l.prop("y"), d[1], key),
|
||||
SetProp.of(l, l.prop("z"), d[2], key));
|
||||
ctx.execute(new Batch("Light direction", c, key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.render.Overlay;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Tool;
|
||||
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.util.Locale;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JSpinner;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Light tool: click to place a point light (1 cell above the surface), drag to set its radius. */
|
||||
final class LightTool implements Tool {
|
||||
record Preset(String name, int r, int g, int b) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
static final Preset[] PRESETS = {
|
||||
new Preset("Torch (orange)", 123, 86, 0),
|
||||
new Preset("Fire (red)", 160, 40, 0),
|
||||
new Preset("Lamp (warm white)", 128, 112, 80),
|
||||
new Preset("Magic (cyan)", 0, 120, 110),
|
||||
new Preset("Moon (blue)", 40, 60, 120),
|
||||
new Preset("Glow (green)", 20, 128, 40)
|
||||
};
|
||||
|
||||
private Preset preset = PRESETS[0];
|
||||
private double height = 1.0;
|
||||
private double radius = 3.0;
|
||||
private Light dragging;
|
||||
private Object key;
|
||||
private Vector3f press;
|
||||
private Vector3f hover;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "env.lights";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "Lights";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String icon() {
|
||||
return "light";
|
||||
}
|
||||
|
||||
@Override
|
||||
public javax.swing.KeyStroke shortcut() {
|
||||
return javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_U, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String tooltip() {
|
||||
return "Place point lights: click to place, drag to set the radius";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String group() {
|
||||
return "env";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int order() {
|
||||
return 310;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activate(EditorContext ctx) {
|
||||
ctx.setLayerVisible("lights", true);
|
||||
ctx.setLayerVisible(EnvOverlays.LAYER_LIGHTS, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mousePressed(ToolEvent e) {
|
||||
EditorContext ctx = e.ctx();
|
||||
if (ctx.scene() == null || e.pick() instanceof Light || e.pick() instanceof EnvZone) {
|
||||
return false;
|
||||
}
|
||||
Vector3f g = e.ground();
|
||||
if (g == null) {
|
||||
return false;
|
||||
}
|
||||
Light l = point(ctx.scene(), g.x, g.y + height, -g.z, preset, Math.min(1, radius / 3), radius);
|
||||
ctx.execute(new AddNode(l));
|
||||
ctx.selection().set(l, this);
|
||||
dragging = l;
|
||||
key = new Object();
|
||||
press = g;
|
||||
return true;
|
||||
}
|
||||
|
||||
static Light point(Scene s, double x, double y, double z, Preset p, double a, double b) {
|
||||
Light l = (Light) s.create(NodeKind.LIGHT);
|
||||
l.setRaw("type", Light.POINT);
|
||||
l.setRaw("r", p.r());
|
||||
l.setRaw("g", p.g());
|
||||
l.setRaw("b", p.b());
|
||||
l.setRaw("x", x);
|
||||
l.setRaw("y", y);
|
||||
l.setRaw("z", z);
|
||||
l.setRaw("a", a);
|
||||
l.setRaw("rangeB", b);
|
||||
return l;
|
||||
}
|
||||
|
||||
static Light directional(Scene s, double azDeg, double elDeg, int r, int g, int b) {
|
||||
Light l = (Light) s.create(NodeKind.LIGHT);
|
||||
double[] d = EnvMath.directionFrom(azDeg, elDeg);
|
||||
l.setRaw("type", Light.DIRECTIONAL);
|
||||
l.setRaw("r", r);
|
||||
l.setRaw("g", g);
|
||||
l.setRaw("b", b);
|
||||
l.setRaw("x", d[0]);
|
||||
l.setRaw("y", d[1]);
|
||||
l.setRaw("z", d[2]);
|
||||
return l;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(ToolEvent e) {
|
||||
if (dragging == null || !e.ctx().scene().contains(dragging)) {
|
||||
return;
|
||||
}
|
||||
Vector3f g = e.onPlane(press.y);
|
||||
if (g == null) {
|
||||
return;
|
||||
}
|
||||
double r = Math.max(0.25, Math.hypot(g.x - press.x, g.z - press.z));
|
||||
e.ctx()
|
||||
.execute(new com.eliasmueller.llamamod.editor.model.cmd.Batch(
|
||||
"Light radius",
|
||||
java.util.List.of(
|
||||
SetProp.of(dragging, dragging.prop("rangeB"), r, key),
|
||||
SetProp.of(dragging, dragging.prop("a"), Math.min(1.0, r * 0.4), key)),
|
||||
key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(ToolEvent e) {
|
||||
if (dragging != null) {
|
||||
e.ctx()
|
||||
.status(String.format(
|
||||
Locale.ROOT,
|
||||
"Point light at %.1f, %.1f (radius %.1f)",
|
||||
dragging.x(),
|
||||
dragging.z(),
|
||||
dragging.rangeB()));
|
||||
}
|
||||
dragging = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved(ToolEvent e) {
|
||||
hover = e.ctx().scene() == null ? null : e.ground();
|
||||
e.ctx().repaintTool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawOverlay(Overlay depthTested, Overlay onTop) {
|
||||
if (hover == null || dragging != null) {
|
||||
return;
|
||||
}
|
||||
Color c =
|
||||
new Color(Math.min(255, preset.r() * 2), Math.min(255, preset.g() * 2), Math.min(255, preset.b() * 2));
|
||||
double[] p = {hover.x, hover.y + height, hover.z};
|
||||
EnvOverlays.sphere(onTop, p, 0.15, EnvOverlays.rgba(c, 255));
|
||||
EnvOverlays.ring(
|
||||
depthTested, new double[] {hover.x, hover.y + 0.03, hover.z}, radius, EnvOverlays.rgba(c, 200));
|
||||
onTop.line(p, new double[] {hover.x, hover.y, hover.z}, EnvOverlays.rgba(c, 160));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JComponent optionsBar(EditorContext ctx) {
|
||||
JComboBox<Preset> p = new JComboBox<>(PRESETS);
|
||||
p.setSelectedItem(preset);
|
||||
p.addActionListener(a -> preset = (Preset) p.getSelectedItem());
|
||||
p.setFocusable(false);
|
||||
JSpinner h = Ui.number(height, 0, 10, 0.25, "0.00", v -> height = v);
|
||||
JSpinner r = Ui.number(radius, 0.25, 32, 0.25, "0.00", v -> radius = v);
|
||||
return Ui.line(new JLabel("Colour"), p, new JLabel("Height"), h, new JLabel("Radius"), r);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor cursor() {
|
||||
return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.render.RenderInstance;
|
||||
import com.eliasmueller.llamamod.editor.render.RenderMesh;
|
||||
import java.util.List;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Per-vertex object lighting like the engine (preview of LGHT lights on object models). */
|
||||
final class ObjectLighting {
|
||||
private ObjectLighting() {}
|
||||
|
||||
/** Vertex colours (0xRRGGBBAA, 0x80 = 1.0) of an object instance lit by the level's lights. */
|
||||
static int[] colors(Scene s, Strat st, RenderInstance ri, double[] ambient) {
|
||||
RenderMesh m = ri.mesh;
|
||||
List<Light> lights = EnvMath.lightsFor(s, st.gx(), st.gy(), st.gz());
|
||||
int nv = m.vertexCount();
|
||||
int[] out = new int[nv];
|
||||
Vector3f p = new Vector3f(), n = new Vector3f();
|
||||
for (int v = 0; v < nv; v++) {
|
||||
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]);
|
||||
n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]);
|
||||
ri.transform.transformPosition(p);
|
||||
ri.transform.transformDirection(n);
|
||||
if (n.lengthSquared() > 0) {
|
||||
n.normalize();
|
||||
}
|
||||
double[] c = EnvMath.shade(ambient, lights, new double[] {p.x, p.y, -p.z}, new double[] {n.x, n.y, -n.z});
|
||||
int r = EnvMath.clamp((int) Math.round(c[0] * 128));
|
||||
int g = EnvMath.clamp((int) Math.round(c[1] * 128));
|
||||
int b = EnvMath.clamp((int) Math.round(c[2] * 128));
|
||||
out[v] = r << 24 | g << 16 | b << 8 | 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Command;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Sets one property on many nodes (e.g. vertex colours of the tiles under a paint brush). Commands
|
||||
* with the same merge key merge even if they touch different nodes (one undo step per stroke): the
|
||||
* first old value and the last new value of every node are kept.
|
||||
*/
|
||||
public final class PropMapCommand implements Command {
|
||||
private final String label;
|
||||
private final String prop;
|
||||
private final Map<Node, Object[]> values;
|
||||
private final Object mergeKey;
|
||||
|
||||
/** newValues: node → value; old values are read now. */
|
||||
public PropMapCommand(String label, String prop, Map<? extends Node, ?> newValues, Object mergeKey) {
|
||||
this.label = label;
|
||||
this.prop = prop;
|
||||
this.mergeKey = mergeKey;
|
||||
this.values = new LinkedHashMap<>();
|
||||
for (Map.Entry<? extends Node, ?> e : newValues.entrySet()) {
|
||||
values.put(e.getKey(), new Object[] {copy(e.getKey().get(prop)), copy(e.getValue())});
|
||||
}
|
||||
}
|
||||
|
||||
private PropMapCommand(String label, String prop, Map<Node, Object[]> values, Object mergeKey, boolean raw) {
|
||||
this.label = label;
|
||||
this.prop = prop;
|
||||
this.values = values;
|
||||
this.mergeKey = mergeKey;
|
||||
}
|
||||
|
||||
private static Object copy(Object v) {
|
||||
if (v instanceof int[] a) {
|
||||
return a.clone();
|
||||
}
|
||||
if (v instanceof double[] a) {
|
||||
return a.clone();
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(Scene s) {
|
||||
for (Map.Entry<Node, Object[]> e : values.entrySet()) {
|
||||
s.set(e.getKey(), prop, copy(e.getValue()[1]));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revert(Scene s) {
|
||||
for (Map.Entry<Node, Object[]> e : values.entrySet()) {
|
||||
s.set(e.getKey(), prop, copy(e.getValue()[0]));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object mergeKey() {
|
||||
return mergeKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Command mergeWith(Command next) {
|
||||
if (!(next instanceof PropMapCommand o) || mergeKey == null || !Objects.equals(mergeKey, o.mergeKey)) {
|
||||
return null;
|
||||
}
|
||||
if (!prop.equals(o.prop)) {
|
||||
return null;
|
||||
}
|
||||
Map<Node, Object[]> m = new LinkedHashMap<>(values);
|
||||
for (Map.Entry<Node, Object[]> e : o.values.entrySet()) {
|
||||
Object[] mine = m.get(e.getKey());
|
||||
m.put(e.getKey(), new Object[] {mine != null ? mine[0] : e.getValue()[0], e.getValue()[1]});
|
||||
}
|
||||
return new PropMapCommand(label, prop, m, mergeKey, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Regions;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Visibility region helpers: automatic layouts (all visible, rectangular blocks, connected areas)
|
||||
* and the cost of a layout (triangles the engine draws for a camera in each region; the tile
|
||||
* renderer FUN_0042bf40 skips cells whose region bit is not in the camera region's mask, a mask of
|
||||
* 0 means everything).
|
||||
*/
|
||||
public final class RegionGen {
|
||||
private RegionGen() {}
|
||||
|
||||
public enum Mode {
|
||||
ALL_VISIBLE("One region, everything visible"),
|
||||
BLOCKS("Rectangular blocks, visible within a distance"),
|
||||
CONNECTED("Connected areas (islands / rooms), visible within a distance");
|
||||
|
||||
public final String label;
|
||||
|
||||
Mode(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
/** Region grid (W·H) and table (mask, value) × 32. */
|
||||
public record Layout(int[] grid, int[] table) {}
|
||||
|
||||
public static Layout generate(Scene s, Mode mode, double distance) {
|
||||
int w = s.settings().width(), h = s.settings().height();
|
||||
int[] table = s.regions().table().length == 2 * Regions.COUNT
|
||||
? s.regions().table().clone()
|
||||
: new int[2 * Regions.COUNT];
|
||||
int[] grid = new int[w * h];
|
||||
int n;
|
||||
switch (mode) {
|
||||
case ALL_VISIBLE -> {
|
||||
for (int r = 0; r < Regions.COUNT; r++) {
|
||||
table[2 * r] = r == 0 ? -1 : 0;
|
||||
}
|
||||
return new Layout(grid, table);
|
||||
}
|
||||
case BLOCKS -> n = blocks(w, h, grid);
|
||||
default -> n = connected(s, w, h, grid);
|
||||
}
|
||||
int[][] box = new int[n][];
|
||||
for (int i = 0; i < n; i++) {
|
||||
box[i] = new int[] {Integer.MAX_VALUE, Integer.MAX_VALUE, -1, -1};
|
||||
}
|
||||
for (int cz = 0; cz < h; cz++) {
|
||||
for (int cx = 0; cx < w; cx++) {
|
||||
int r = grid[cx + cz * w];
|
||||
int[] b = box[r];
|
||||
b[0] = Math.min(b[0], cx);
|
||||
b[1] = Math.min(b[1], cz);
|
||||
b[2] = Math.max(b[2], cx);
|
||||
b[3] = Math.max(b[3], cz);
|
||||
}
|
||||
}
|
||||
for (int r = 0; r < Regions.COUNT; r++) {
|
||||
table[2 * r] = 0;
|
||||
}
|
||||
for (int a = 0; a < n; a++) {
|
||||
int m = 0;
|
||||
for (int b = 0; b < n; b++) {
|
||||
if (a == b || boxDistance(box[a], box[b]) <= distance) {
|
||||
m |= 1 << b;
|
||||
}
|
||||
}
|
||||
table[2 * a] = m;
|
||||
}
|
||||
return new Layout(grid, table);
|
||||
}
|
||||
|
||||
private static double boxDistance(int[] a, int[] b) {
|
||||
double dx = Math.max(0, Math.max(a[0] - b[2], b[0] - a[2]) - 1);
|
||||
double dz = Math.max(0, Math.max(a[1] - b[3], b[1] - a[3]) - 1);
|
||||
return Math.sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
private static int blocks(int w, int h, int[] grid) {
|
||||
int bx = 1, bz = 1;
|
||||
while (bx * bz < Regions.COUNT) {
|
||||
if ((double) w / bx >= (double) h / bz && (bx + 1) * bz <= Regions.COUNT) {
|
||||
bx++;
|
||||
} else if (bx * (bz + 1) <= Regions.COUNT) {
|
||||
bz++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int cz = 0; cz < h; cz++) {
|
||||
for (int cx = 0; cx < w; cx++) {
|
||||
grid[cx + cz * w] = Math.min(bx - 1, cx * bx / w) + Math.min(bz - 1, cz * bz / h) * bx;
|
||||
}
|
||||
}
|
||||
return bx * bz;
|
||||
}
|
||||
|
||||
/** Flood fill over cells with tiles (neighbours within 2.5 height), ≤ 32 areas, empty cells to the nearest. */
|
||||
private static int connected(Scene s, int w, int h, int[] grid) {
|
||||
double[] top = topHeights(s, w, h);
|
||||
int[] lab = new int[w * h];
|
||||
Arrays.fill(lab, -1);
|
||||
List<List<Integer>> areas = new ArrayList<>();
|
||||
int[] dx = {1, -1, 0, 0}, dz = {0, 0, 1, -1};
|
||||
for (int i = 0; i < w * h; i++) {
|
||||
if (Double.isNaN(top[i]) || lab[i] >= 0) {
|
||||
continue;
|
||||
}
|
||||
List<Integer> area = new ArrayList<>();
|
||||
ArrayDeque<Integer> q = new ArrayDeque<>();
|
||||
q.add(i);
|
||||
lab[i] = areas.size();
|
||||
while (!q.isEmpty()) {
|
||||
int c = q.poll();
|
||||
area.add(c);
|
||||
int cx = c % w, cz = c / w;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
int nx = cx + dx[k], nz = cz + dz[k];
|
||||
if (nx < 0 || nz < 0 || nx >= w || nz >= h) {
|
||||
continue;
|
||||
}
|
||||
int nc = nx + nz * w;
|
||||
if (lab[nc] < 0 && !Double.isNaN(top[nc]) && Math.abs(top[nc] - top[c]) < 2.5) {
|
||||
lab[nc] = lab[i];
|
||||
q.add(nc);
|
||||
}
|
||||
}
|
||||
}
|
||||
areas.add(area);
|
||||
}
|
||||
if (areas.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
// merge the smallest areas into their nearest neighbour until 32 remain
|
||||
int[] owner = new int[areas.size()];
|
||||
for (int i = 0; i < owner.length; i++) {
|
||||
owner[i] = i;
|
||||
}
|
||||
int alive = areas.size();
|
||||
int[] size = areas.stream().mapToInt(List::size).toArray();
|
||||
double[][] centre = new double[areas.size()][2];
|
||||
for (int a = 0; a < areas.size(); a++) {
|
||||
for (int c : areas.get(a)) {
|
||||
centre[a][0] += c % w;
|
||||
centre[a][1] += c / w;
|
||||
}
|
||||
centre[a][0] /= size[a];
|
||||
centre[a][1] /= size[a];
|
||||
}
|
||||
while (alive > Regions.COUNT) {
|
||||
int small = -1;
|
||||
for (int a = 0; a < owner.length; a++) {
|
||||
if (owner[a] == a && (small < 0 || size[a] < size[small])) {
|
||||
small = a;
|
||||
}
|
||||
}
|
||||
int best = -1;
|
||||
double bd = Double.MAX_VALUE;
|
||||
for (int a = 0; a < owner.length; a++) {
|
||||
if (owner[a] == a && a != small) {
|
||||
double d =
|
||||
EnvMath.sq(centre[a][0] - centre[small][0]) + EnvMath.sq(centre[a][1] - centre[small][1]);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int a = 0; a < owner.length; a++) {
|
||||
if (owner[a] == small) {
|
||||
owner[a] = best;
|
||||
}
|
||||
}
|
||||
centre[best][0] =
|
||||
(centre[best][0] * size[best] + centre[small][0] * size[small]) / (size[best] + size[small]);
|
||||
centre[best][1] =
|
||||
(centre[best][1] * size[best] + centre[small][1] * size[small]) / (size[best] + size[small]);
|
||||
size[best] += size[small];
|
||||
alive--;
|
||||
}
|
||||
int[] index = new int[owner.length];
|
||||
Arrays.fill(index, -1);
|
||||
int n = 0;
|
||||
for (int a = 0; a < owner.length; a++) {
|
||||
if (owner[a] == a) {
|
||||
index[a] = n++;
|
||||
}
|
||||
}
|
||||
// multi-source BFS: every cell gets the region of the nearest occupied cell
|
||||
Arrays.fill(grid, -1);
|
||||
ArrayDeque<Integer> q = new ArrayDeque<>();
|
||||
for (int i = 0; i < w * h; i++) {
|
||||
if (lab[i] >= 0) {
|
||||
grid[i] = index[owner[lab[i]]];
|
||||
q.add(i);
|
||||
}
|
||||
}
|
||||
while (!q.isEmpty()) {
|
||||
int c = q.poll();
|
||||
int cx = c % w, cz = c / w;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
int nx = cx + dx[k], nz = cz + dz[k];
|
||||
if (nx >= 0 && nz >= 0 && nx < w && nz < h && grid[nx + nz * w] < 0) {
|
||||
grid[nx + nz * w] = grid[c];
|
||||
q.add(nx + nz * w);
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Highest tile y per cell (NaN = no tile). */
|
||||
static double[] topHeights(Scene s, int w, int h) {
|
||||
double[] top = new double[w * h];
|
||||
Arrays.fill(top, Double.NaN);
|
||||
for (Tile t : s.tiles()) {
|
||||
int cx = t.cellX(), cz = t.cellZ();
|
||||
if (cx >= 0
|
||||
&& cz >= 0
|
||||
&& cx < w
|
||||
&& cz < h
|
||||
&& t.model() != null
|
||||
&& t.model().faceCount() > 0) {
|
||||
int i = cx + cz * w;
|
||||
top[i] = Double.isNaN(top[i]) ? t.y() : Math.max(top[i], t.y());
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/** Triangles per region (cells of the region), index = region. */
|
||||
public static long[] trianglesPerRegion(Scene s, int[] grid) {
|
||||
int w = s.settings().width(), h = s.settings().height();
|
||||
long[] tris = new long[Regions.COUNT];
|
||||
for (Tile t : s.tiles()) {
|
||||
int cx = t.cellX(), cz = t.cellZ();
|
||||
if (cx >= 0 && cz >= 0 && cx < w && cz < h && t.model() != null && cx + cz * w < grid.length) {
|
||||
tris[grid[cx + cz * w] & 31] += t.model().faceCount();
|
||||
}
|
||||
}
|
||||
return tris;
|
||||
}
|
||||
|
||||
/** Triangles drawn for a camera in each region (all regions in its mask; mask 0 = all). */
|
||||
public static long[] visibleTriangles(Scene s, int[] grid, int[] table) {
|
||||
long[] per = trianglesPerRegion(s, grid);
|
||||
long all = Arrays.stream(per).sum();
|
||||
long[] out = new long[Regions.COUNT];
|
||||
for (int r = 0; r < Regions.COUNT; r++) {
|
||||
int m = table[2 * r];
|
||||
if (m == 0) {
|
||||
out[r] = all;
|
||||
continue;
|
||||
}
|
||||
for (int b = 0; b < Regions.COUNT; b++) {
|
||||
if ((m & (1 << b)) != 0) {
|
||||
out[r] += per[b];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Regions that occur in the grid. */
|
||||
public static boolean[] used(int[] grid) {
|
||||
boolean[] u = new boolean[Regions.COUNT];
|
||||
for (int g : grid) {
|
||||
u[g & 31] = true;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Above this many triangles visible from one region the game gets slow on old hardware. */
|
||||
public static final long HEAVY = 30000;
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.environment;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Regions;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.render.Overlay;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.Tool;
|
||||
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Paints visibility regions onto grid cells (square brush); Ctrl+click picks the region of a cell. */
|
||||
final class RegionPaintTool implements Tool {
|
||||
int region = 1;
|
||||
int size = 1;
|
||||
private Object stroke;
|
||||
private Vector3f hover;
|
||||
private JComboBox<Integer> regionBox;
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return "env.regions";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "Paint regions";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String icon() {
|
||||
return "region";
|
||||
}
|
||||
|
||||
@Override
|
||||
public javax.swing.KeyStroke shortcut() {
|
||||
return javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_Y, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String tooltip() {
|
||||
return "Paint visibility regions on grid cells (Ctrl+click picks a region)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String group() {
|
||||
return "env";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int order() {
|
||||
return 330;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activate(EditorContext ctx) {
|
||||
ctx.setLayerVisible("regions", true);
|
||||
}
|
||||
|
||||
void setRegion(int r) {
|
||||
region = r & 31;
|
||||
if (regionBox != null && !Integer.valueOf(region).equals(regionBox.getSelectedItem())) {
|
||||
regionBox.setSelectedItem(region);
|
||||
}
|
||||
}
|
||||
|
||||
private int[] cell(Vector3f p) {
|
||||
return new int[] {(int) Math.floor(p.x), (int) Math.floor(-p.z)};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mousePressed(ToolEvent e) {
|
||||
Scene s = e.ctx().scene();
|
||||
Vector3f p = e.ground();
|
||||
if (s == null || p == null) {
|
||||
return s != null;
|
||||
}
|
||||
int[] c = cell(p);
|
||||
if (e.ctrl()) {
|
||||
int r = s.regions().regionAt(c[0], c[1], s.settings().width());
|
||||
if (r >= 0) {
|
||||
setRegion(r);
|
||||
e.ctx().status("Region " + r);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
stroke = new Object();
|
||||
paint(e.ctx(), c);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(ToolEvent e) {
|
||||
Vector3f p = e.ground();
|
||||
hover = p;
|
||||
if (stroke != null && p != null) {
|
||||
paint(e.ctx(), cell(p));
|
||||
}
|
||||
e.ctx().repaintTool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(ToolEvent e) {
|
||||
stroke = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved(ToolEvent e) {
|
||||
hover = e.ctx().scene() == null ? null : e.ground();
|
||||
e.ctx().repaintTool();
|
||||
}
|
||||
|
||||
private void paint(EditorContext ctx, int[] c) {
|
||||
Scene s = ctx.scene();
|
||||
Regions rg = s.regions();
|
||||
int w = s.settings().width(), h = s.settings().height();
|
||||
int[] grid = rg.grid();
|
||||
if (grid.length != w * h) {
|
||||
return;
|
||||
}
|
||||
int[] g = null;
|
||||
int half = size / 2;
|
||||
for (int z = c[1] - half; z <= c[1] + half; z++) {
|
||||
for (int x = c[0] - half; x <= c[0] + half; x++) {
|
||||
if (x < 0 || z < 0 || x >= w || z >= h || grid[x + z * w] == region) {
|
||||
continue;
|
||||
}
|
||||
if (g == null) {
|
||||
g = grid.clone();
|
||||
}
|
||||
g[x + z * w] = region;
|
||||
}
|
||||
}
|
||||
if (g != null) {
|
||||
ctx.execute(SetProp.of(rg, rg.prop("grid"), g, stroke));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawOverlay(Overlay depthTested, Overlay onTop) {
|
||||
if (hover == null) {
|
||||
return;
|
||||
}
|
||||
int[] c = cell(hover);
|
||||
int half = size / 2;
|
||||
double y = hover.y + 0.05;
|
||||
int col = EnvOverlays.rgba(EnvMath.regionColor(region), 255);
|
||||
onTop.box(c[0] - half, y, -(c[1] + half) - 1, c[0] + half + 1, y + 0.2, -(c[1] - half), col);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JComponent optionsBar(EditorContext ctx) {
|
||||
Integer[] items = new Integer[Regions.COUNT];
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
items[i] = i;
|
||||
}
|
||||
regionBox = new JComboBox<>(items);
|
||||
regionBox.setMaximumRowCount(16);
|
||||
regionBox.setRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) {
|
||||
JLabel lab = (JLabel) super.getListCellRendererComponent(l, "Region " + v, i, s, f);
|
||||
lab.setIcon(Icons.swatch(EnvMath.regionColor((Integer) v), 14, 12));
|
||||
return lab;
|
||||
}
|
||||
});
|
||||
regionBox.setSelectedItem(region);
|
||||
regionBox.addActionListener(a -> {
|
||||
if (regionBox.getSelectedItem() instanceof Integer r && r != region) {
|
||||
setRegion(r);
|
||||
}
|
||||
});
|
||||
regionBox.setFocusable(false);
|
||||
JComboBox<String> sz = new JComboBox<>(new String[] {"1 × 1", "3 × 3", "5 × 5", "9 × 9", "17 × 17"});
|
||||
int[] sizes = {1, 3, 5, 9, 17};
|
||||
for (int i = 0; i < sizes.length; i++) {
|
||||
if (sizes[i] == size) {
|
||||
sz.setSelectedIndex(i);
|
||||
}
|
||||
}
|
||||
sz.addActionListener(a -> size = sizes[sz.getSelectedIndex()]);
|
||||
sz.setFocusable(false);
|
||||
JLabel hint = new JLabel("Ctrl+click: pick · masks in the Visibility panel");
|
||||
hint.setEnabled(false);
|
||||
return Ui.line(new JLabel("Region"), regionBox, new JLabel("Brush"), sz, hint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor cursor() {
|
||||
return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 826 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user