diff --git a/CHANGELOG.md b/CHANGELOG.md index df057af8..4fab0ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,39 @@ way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). ## [Unreleased] +## [1.1.0-beta.7] - 2026-11-06 + +Phase 11 of the road to 1.1.0: the first half of the vehicle workshop. Betas are not uploaded to +CurseForge, and this one ships the Forge 26.3 jar only. Worlds from 1.1.0-beta.6 load as before. +Players and servers need the same version, since the network protocol changed (10). + ### Added - Placeholder loader modules for every supported version that is not ported yet: Forge, NeoForge and Fabric on 26.2, 26.1, 1.21.11, 1.21.5 and 1.20.6, and Forge and Fabric on 1.20.2 and 1.19.4. Each downloads its loader and only logs at startup; none of them is built by default or shipped in a beta. See docs/DEVELOPMENT.md. +- The vehicle workshop, first half: build a vehicle in game from a Blockbench or Blender model. The + drafting table's Workshop button opens it; the guide is docs/content/workshop.md. + - Import an OBJ by dropping it on the window or pasting its path. Files that cannot be used are + refused with the reason, and suspicious ones (no `$body`, no texture coordinates, a size that + suggests the wrong scale) open with a warning. + - A 3D editor around the model, drawn by the game's own vehicle renderer. Gizmos place seats, + cameras, rotors, propellers, wings, flaps, nozzles, wheels, tracks, landing gear, canopies, + hatches, weapons, hit boxes, the engine and the fuel tank, with a snap to 1/16 of a block. + - A tuning page for speed, turn rates, gravity, fuel, hit points, armor and so on, listing only + what the kind has and showing the real defaults. + - Test flight from the editor in a world you host, on a vehicle that takes no damage and goes away + when you get out, back to the editor. + - Undo and redo. Save writes an ordinary pack folder to `mcheli/packs`, keeping comments and any + keys the editor has no panel for. Export writes a zip to share to `mcheli/workshop`. Any pack + vehicle with an OBJ model can be opened; one in a zip opens from an `-edit` copy. +- `EnginePosition` and `FuelTankPosition` definition keys: where a wreck burns and where a crash + explodes. ### Changed +- OBJ models may have faces with more than four corners, and a bad line now names its line number. - The supported version list for final releases is now 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1, and 1.20.6 on Forge, NeoForge and Fabric, and 1.20.2 and 1.19.4 on Forge and Fabric (25 jars). 1.20.1 is no longer planned, and older versions are no longer frozen after 1.1.0. @@ -643,6 +667,7 @@ Phase 1: foundation and flyable helicopters. - Free look moved from Left Ctrl to Left Alt, because Left Ctrl is now the vanilla sprint key. - Roll damping runs per tick instead of per frame, so handling no longer depends on frame rate. +[1.1.0-beta.7]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.7 [1.1.0-beta.6]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.6 [1.1.0-beta.5]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.5 [1.1.0-beta.4]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.4 diff --git a/README.md b/README.md index 503caac7..82ca27a1 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ MCHeli Rework is a ground-up port of MCHeli 1.0.3, a mod that stopped at Minecra system is rewritten for the modern game, while the original vehicle definitions, models, textures and sounds load unchanged. -> **Status:** on the way to 1.1.0, now at phase 10 (`1.1.0-beta.6`). Every helicopter, plane, +> **Status:** on the way to 1.1.0, now at phase 11 (`1.1.0-beta.7`). Every helicopter, plane, > tank, car and ground vehicle can be placed, crewed and driven with the original handling, all > 229 weapons fire, and the scripted HUDs, fuel, the vehicle screen and all of the original's -> equipment are in, down to UAV stations, the drafting table and the team manager. Betas ship the +> equipment are in, down to UAV stations, the drafting table and the team manager. The vehicle +> workshop builds new vehicles in game from a Blockbench or Blender model. Betas ship the > Forge 26.3 jar only; 1.1.0 ships on Forge and Fabric for every version from 1.19.4 to 26.3, and > on NeoForge from 1.20.6 (see [supported versions](docs/ROADMAP.md#supported-versions)). See the > [roadmap](docs/ROADMAP.md). @@ -32,20 +33,19 @@ Phase 4 clips (click a picture to open the video): ## Screenshots -Phase 10 in a development build (`1.1.0-beta.6`): smoke grenades, the GLTD target designator, the -rangefinder, cargo containers and the tow chain. Every earlier phase is in the +The vehicle workshop in a development build (`1.1.0-beta.7`): bring a model +from Blockbench or Blender, place its parts on it, tune it and fly it. The helicopter here is the +example pack's Rework Scout. Every earlier phase is in the [screenshot gallery](docs/SCREENSHOTS.md). | | | |---|---| -| ![A green M18 smoke cloud rising from a hilltop by a lake](docs/images/phase-10/m18-green-smoke.png) | ![The view through the GLTD sight over a desert village, a red cross on the target and the readout "A-10 on call"](docs/images/phase-10/gltd-sight.png) | -| An M18 smoke grenade marks a spot in green | Through the GLTD's sight, with the A-10 on call | -| ![Two A-10s pulling away over a desert hill after strafing it, the readout counting down "A-10 rearming"](docs/images/phase-10/gltd-a-10-strike.png) | ![A player sitting behind the GLTD tripod, its red laser running out over the sand](docs/images/phase-10/gltd-tripod.png) | -| The A-10 makes its gun runs over the marked spot | The GLTD from outside: the head follows the aim, with a faint laser | -| ![The rangefinder scope on an AH-64 at 163 metres, the spot modes listed at the side](docs/images/phase-10/rangefinder-scope.png) | ![A cargo container coming down over a desert under two parachutes](docs/images/phase-10/container-parachute-drop.png) | -| The rangefinder reads the range to an AH-64 | A container dropped from a rack comes down under a parachute | -| ![A cargo container chained to the side of an M1A2 tank](docs/images/phase-10/chain-tow-m1a2.png) | ![A CH-47 flying over canyons with a container hanging below it on the tow chain](docs/images/phase-10/ch47-sling-load.png) | -| An M1A2 with a container on the tow chain | A CH-47 carries a container as a sling load | +| ![The workshop editor: a small helicopter on a grid, a hit box outlined around its cockpit, coloured handles on its parts, and the main rotor selected with arrows, its axis and its values in the side panel](docs/images/phase-11/workshop-editor.png) | ![The tuning page beside the helicopter, listing hit points, top speed, gravity, turn rates, fuel and armor](docs/images/phase-11/workshop-tuning.png) | +| Place every part by dragging it on the model | Tune how it flies; values you changed stand out from the defaults | +| ![The workshop's first screen: vehicles in the packs on the left, and a form for a new vehicle with a dropped OBJ file on the right](docs/images/phase-11/workshop-import.png) | ![The view from the helicopter's cockpit in flight over a desert coast, with the instruments on the HUD](docs/images/phase-11/workshop-test-flight.png) | +| Drop an OBJ on the window to start a vehicle | Test flight straight from the editor | +| ![The pack page with the vehicle's name, author, version and description, and a message that the pack was exported to a zip](docs/images/phase-11/workshop-pack.png) | ![The finished helicopter parked on a red desert hill, a CH-47 flying past behind it](docs/images/phase-11/workshop-finished-vehicle.png) | +| Name the pack and export a zip to share | The result is an ordinary content pack | ## Credits @@ -135,7 +135,8 @@ chain), troops, the team manager and the commands and server settings are in Content packs add vehicles and weapons: drop a pack (a zip or a folder) into `/mcheli/packs`. On a server only the server needs it; players download the server's packs automatically when they join. [examples/rework-example-pack](examples/rework-example-pack) -adds a small helicopter, the Rework Scout. To make your own, see +adds a small helicopter, the Rework Scout. To make your own, use the +[workshop](docs/content/workshop.md) in game, or write one by hand with [docs/content/packs.md](docs/content/packs.md). ## Documentation @@ -147,7 +148,7 @@ adds a small helicopter, the Rework Scout. To make your own, see * [Architecture](docs/ARCHITECTURE.md): how the mod is put together * [Development](docs/DEVELOPMENT.md): building, running, testing and releasing * [In-game test plan](docs/TESTING.md) -* Content authoring: [content packs](docs/content/packs.md), [modeling in Blockbench or Blender](docs/content/modeling.md), [vehicles](docs/content/aircraft.md), [weapons](docs/content/weapons.md), [HUD scripts](docs/content/hud.md) +* Content authoring: [the workshop](docs/content/workshop.md), [content packs](docs/content/packs.md), [modeling in Blockbench or Blender](docs/content/modeling.md), [vehicles](docs/content/aircraft.md), [weapons](docs/content/weapons.md), [HUD scripts](docs/content/hud.md) * [Contributing](CONTRIBUTING.md): rules for pull requests and bug reports ## Building from source diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java b/common/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java index d67ae0bb..404beea6 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java @@ -34,10 +34,15 @@ private DefinitionReader() { } public static Result read(Path file, T definition) throws IOException { + return read(decode(Files.readAllBytes(file)), definition); + } + + /** Reads text that is already in memory, such as the definition open in the workshop. */ + public static Result read(String text, T definition) throws IOException { List problems = new ArrayList<>(); Set unhandled = new LinkedHashSet<>(); - try (BufferedReader reader = new BufferedReader(new StringReader(decode(Files.readAllBytes(file))))) { + try (BufferedReader reader = new BufferedReader(new StringReader(text))) { String line; int lineNumber = 0; while ((line = reader.readLine()) != null) { @@ -78,7 +83,7 @@ public static Result read(Path file, T definition) thr * Files are meant to be UTF-8, but a few of the shipped ones were saved as Shift-JIS by the * original author's editor. Fall back to Windows-31J rather than mangling those comments. */ - static String decode(byte[] bytes) { + public static String decode(byte[] bytes) { try { return StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java index f27233fc..db0e6a2f 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java @@ -77,6 +77,10 @@ public abstract class AircraftDefinition extends Definition { public float repairOtherVehiclesRange; public int repairOtherVehiclesValue = 10; public int inventorySize; + /** {@code EnginePosition}, not in the original: where a wreck burns. Its middle when unset. */ + public @Nullable Vector3 enginePosition; + /** {@code FuelTankPosition}, not in the original: where a crash goes up. Its origin when unset. */ + public @Nullable Vector3 fuelTankPosition; // Body and physics public float bodyWidth = 2.0F; @@ -368,6 +372,18 @@ protected void registerKeys(KeyTable keys) { } }); keys.put("InventorySize", v -> inventorySize = toInt(v, 0, 54)); + keys.put("EnginePosition", v -> { + String[] s = split(v); + if (s.length >= 3) { + enginePosition = toVec(s[0], s[1], s[2]); + } + }); + keys.put("FuelTankPosition", v -> { + String[] s = split(v); + if (s.length >= 3) { + fuelTankPosition = toVec(s[0], s[1], s[2]); + } + }); keys.put("Width", v -> bodyWidth = toFloat(v, 0.1F, 1000.0F)); keys.put("Height", v -> bodyHeight = toFloat(v, 0.1F, 1000.0F)); diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java index f0824fda..5f174e09 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java @@ -40,6 +40,21 @@ public static PackManifest read(InputStream in) throws IOException, PackExceptio value(p, "version", "1.0.0", 32), value(p, "description", "", 256)); } + /** The file's text, for the workshop. {@link #read} gives back the same values. */ + public String text() { + return "id = " + escape(id) + "\nname = " + escape(name) + "\nauthor = " + escape(author) + + "\nversion = " + escape(version) + "\ndescription = " + escape(description) + "\n"; + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\n", " ").replace("\r", " "); + } + + /** Whether a text can be a pack id. */ + public static boolean isValidId(String id) { + return ID.matcher(id).matches() && !id.equals(ContentSource.ORIGINAL); + } + private static String value(Properties p, String key, String fallback, int maxLength) { String value = p.getProperty(key, "").trim(); if (value.isEmpty()) { diff --git a/common/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java b/common/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java index 360a9564..eae7c6bd 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java +++ b/common/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java @@ -12,9 +12,12 @@ * Reader for Wavefront {@code .obj} files. * *

Follows {@code W_WavefrontObject}: {@code g} always starts a new group, {@code o} only does - * so when its name begins with {@code $}. Texture V is flipped, positions are used unscaled, and - * quads are split into two triangles. Normals from the file are used when present, otherwise the - * face normal is computed. + * so when its name begins with {@code $}. Texture V is flipped and positions are used unscaled. + * Faces with more than three corners are split into a fan of triangles. Normals from the file are + * used when present, otherwise the face normal is computed. + * + *

A line that cannot be read fails the whole file with a {@link ModelFormatException} naming + * the line, because players see these messages when they import a model into the workshop. */ public final class ObjLoader { private ObjLoader() { @@ -39,36 +42,42 @@ public static Mesh load(String source, InputStream in) throws IOException { continue; } String[] t = line.split(" "); - switch (t[0]) { - case "v" -> positions.add(floats(t, 3)); - case "vn" -> normals.add(floats(t, 3)); - case "vt" -> { - float[] uv = floats(t, 2); - uv[1] = 1.0F - uv[1]; - uvs.add(uv); - } - case "g", "o" -> { - if (t.length < 2 || (t[0].equals("o") && !t[1].startsWith("$"))) { - continue; + try { + switch (t[0]) { + case "v" -> positions.add(floats(t, 3)); + case "vn" -> normals.add(floats(t, 3)); + case "vt" -> { + float[] uv = floats(t, 2); + uv[1] = 1.0F - uv[1]; + uvs.add(uv); } - if (builder != null) { - groups.add(builder.build(groupName)); - } - groupName = t[1]; - builder = new TriangleBuilder(); - } - case "f" -> { - if (builder == null) { + case "g", "o" -> { + if (t.length < 2 || (t[0].equals("o") && !t[1].startsWith("$"))) { + continue; + } + if (builder != null) { + groups.add(builder.build(groupName)); + } + groupName = t[1]; builder = new TriangleBuilder(); } - if (t.length != 4 && t.length != 5) { - throw new ModelFormatException("%s:%d only triangles and quads are supported".formatted(source, lineNumber)); + case "f" -> { + if (builder == null) { + builder = new TriangleBuilder(); + } + if (t.length < 4) { + throw new ModelFormatException("a face needs at least three corners"); + } + addFace(builder, t, positions, normals, uvs); + } + default -> { + // mtllib, usemtl, s and friends carry nothing the renderer needs. } - addFace(builder, t, positions, normals, uvs); - } - default -> { - // mtllib, usemtl, s and friends carry nothing the renderer needs. } + } catch (ModelFormatException e) { + throw new ModelFormatException("%s, line %d: %s".formatted(source, lineNumber, e.getMessage())); + } catch (NumberFormatException e) { + throw new ModelFormatException("%s, line %d: not a number in \"%s\"".formatted(source, lineNumber, line)); } } } @@ -78,20 +87,20 @@ public static Mesh load(String source, InputStream in) throws IOException { return new Mesh(source, groups); } - private static void addFace(TriangleBuilder builder, String[] t, List positions, List normals, List uvs) { + private static void addFace(TriangleBuilder builder, String[] t, List positions, List normals, List uvs) + throws ModelFormatException { int corners = t.length - 1; float[][] p = new float[corners][]; float[][] n = new float[corners][]; float[][] uv = new float[corners][]; for (int i = 0; i < corners; i++) { String[] parts = t[i + 1].split("/", -1); - p[i] = positions.get(index(parts[0], positions.size())); - uv[i] = parts.length > 1 && !parts[1].isEmpty() ? uvs.get(index(parts[1], uvs.size())) : new float[2]; - n[i] = parts.length > 2 && !parts[2].isEmpty() ? normals.get(index(parts[2], normals.size())) : null; + p[i] = element(positions, parts[0], "vertex"); + uv[i] = parts.length > 1 && !parts[1].isEmpty() ? element(uvs, parts[1], "texture coordinate") : new float[2]; + n[i] = parts.length > 2 && !parts[2].isEmpty() ? element(normals, parts[2], "normal") : null; } - emit(builder, p, n, uv, 0, 1, 2); - if (corners == 4) { - emit(builder, p, n, uv, 0, 2, 3); + for (int i = 1; i + 1 < corners; i++) { + emit(builder, p, n, uv, 0, i, i + 1); } } @@ -104,9 +113,13 @@ private static void emit(TriangleBuilder builder, float[][] p, float[][] n, floa } /** OBJ indices are one based and may be negative, counting back from the last element. */ - private static int index(String token, int size) { + private static float[] element(List list, String token, String what) throws ModelFormatException { int i = Integer.parseInt(token); - return i < 0 ? size + i : i - 1; + int index = i < 0 ? list.size() + i : i - 1; + if (index < 0 || index >= list.size()) { + throw new ModelFormatException("a face uses %s %d, but only %d exist at this point".formatted(what, i, list.size())); + } + return list.get(index); } private static float[] floats(String[] t, int count) { diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java new file mode 100644 index 00000000..117082c8 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java @@ -0,0 +1,132 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.DefinitionValues; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * A definition file as the workshop edits it: its lines, in order, as text. + * + *

The editor changes single lines and leaves all others exactly as they were, so comments, keys + * the editor has no panel for and keys from newer versions survive a round trip. The rules for what + * counts as an entry are those of {@code DefinitionReader}. + */ +public final class DefinitionDocument { + private final List lines = new ArrayList<>(); + + /** One {@code key = value} line. */ + public record Entry(int line, String key, String value) { + } + + public DefinitionDocument(String text) { + String clean = !text.isEmpty() && text.charAt(0) == '' ? text.substring(1) : text; + lines.addAll(clean.lines().toList()); + } + + /** The file's text, with a line break after every line. */ + public String text() { + StringBuilder out = new StringBuilder(); + for (String line : lines) { + out.append(line).append('\n'); + } + return out.toString(); + } + + /** A copy of the lines, for the undo history. */ + public List snapshot() { + return List.copyOf(lines); + } + + public void restore(List snapshot) { + lines.clear(); + lines.addAll(snapshot); + } + + public int size() { + return lines.size(); + } + + /** Every entry in file order. Comments, blank lines and lines with no value are left out. */ + public List entries() { + List entries = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + Entry entry = entry(i); + if (entry != null) { + entries.add(entry); + } + } + return entries; + } + + /** The entry on a line, or {@code null} if that line holds none. */ + public @Nullable Entry entry(int line) { + String text = lines.get(line).trim(); + if (text.isEmpty() || text.startsWith(";") || text.startsWith("#") || text.startsWith("//")) { + return null; + } + int eq = text.indexOf('='); + if (eq < 0 || eq == text.length() - 1) { + return null; + } + return new Entry(line, text.substring(0, eq).trim(), text.substring(eq + 1).trim()); + } + + /** The value that takes effect for a single value key: the last one, as lines apply in order. */ + public @Nullable String value(String key) { + Entry entry = last(key); + return entry != null ? entry.value() : null; + } + + private @Nullable Entry last(String key) { + String wanted = DefinitionValues.lower(key); + for (int i = lines.size() - 1; i >= 0; i--) { + Entry entry = entry(i); + if (entry != null && DefinitionValues.lower(entry.key()).equals(wanted)) { + return entry; + } + } + return null; + } + + /** + * Sets a single value key: changes the line that takes effect, or adds one at the end. With + * {@code null} every line of the key goes, which brings back the default. + */ + public void set(String key, @Nullable String value) { + if (value == null) { + String wanted = DefinitionValues.lower(key); + for (int i = lines.size() - 1; i >= 0; i--) { + Entry entry = entry(i); + if (entry != null && DefinitionValues.lower(entry.key()).equals(wanted)) { + lines.remove(i); + } + } + return; + } + Entry entry = last(key); + if (entry != null) { + setLine(entry.line(), entry.key(), value); + } else { + lines.add(format(key, value)); + } + } + + public void setLine(int line, String key, String value) { + lines.set(line, format(key, value)); + } + + /** Puts a new line before the line now at {@code line}, moving that one and the rest down. */ + public void insert(int line, String key, String value) { + lines.add(line, format(key, value)); + } + + public void removeLine(int line) { + lines.remove(line); + } + + private static String format(String key, String value) { + return key + " = " + value; + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/EditHistory.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/EditHistory.java new file mode 100644 index 00000000..2a496184 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/EditHistory.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli.workshop; + +import org.jspecify.annotations.Nullable; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Objects; + +/** + * Undo and redo for the workshop, as whole snapshots of what is edited: a definition is small, so + * this is simpler and safer than undoing each kind of change by hand. + * + *

Changes with the same merge key that follow each other count as one step, so typing a number + * or dragging a gizmo undoes in one go rather than one character or one mouse move at a time. + * + * @param the snapshot, which must not change once handed over + */ +public final class EditHistory { + private static final int MAX_STEPS = 200; + + private final Deque undo = new ArrayDeque<>(); + private final Deque redo = new ArrayDeque<>(); + private @Nullable Object lastMergeKey; + + /** + * Call before changing anything. + * + * @param before the state before the change + * @param mergeKey groups this change with the previous one if the keys are equal; {@code null} + * always starts a new step + */ + public void record(T before, @Nullable Object mergeKey) { + redo.clear(); + if (mergeKey != null && Objects.equals(mergeKey, lastMergeKey)) { + return; + } + lastMergeKey = mergeKey; + undo.push(before); + if (undo.size() > MAX_STEPS) { + undo.removeLast(); + } + } + + /** Ends merging, so the next change is a new step even with the same key. */ + public void endMerge() { + lastMergeKey = null; + } + + public boolean canUndo() { + return !undo.isEmpty(); + } + + public boolean canRedo() { + return !redo.isEmpty(); + } + + /** @return the state to go back to; {@code current} becomes what redo returns to */ + public T undo(T current) { + lastMergeKey = null; + redo.push(current); + return undo.pop(); + } + + /** @return the state to go forward to; {@code current} becomes what undo returns to */ + public T redo(T current) { + lastMergeKey = null; + undo.push(current); + return redo.pop(); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/ModelImport.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/ModelImport.java new file mode 100644 index 00000000..dfc63854 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/ModelImport.java @@ -0,0 +1,224 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.CameraPart; +import com.jnhprojects.mcheli.content.aircraft.part.CrawlerTrack; +import com.jnhprojects.mcheli.content.aircraft.part.LandingGearPart; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.content.aircraft.part.PropellerPart; +import com.jnhprojects.mcheli.content.aircraft.part.RotatingPart; +import com.jnhprojects.mcheli.content.aircraft.part.ThrottlePart; +import com.jnhprojects.mcheli.content.aircraft.part.TrackRoller; +import com.jnhprojects.mcheli.content.aircraft.part.VehiclePart; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponBayPart; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; +import com.jnhprojects.mcheli.content.aircraft.part.WheelPart; +import com.jnhprojects.mcheli.content.aircraft.part.WingPart; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.ObjLoader; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Checks a model file before the workshop takes it in, and says what is wrong in terms its author + * can act on. A file that cannot be used is an error; something that loads but will probably look + * wrong in game is a warning. + * + * @param warnings what the author may want to fix + * @param min the smallest corner of the box around the model + * @param max the largest corner of the box around the model + */ +public record ModelImport(Mesh mesh, List warnings, float[] min, float[] max) { + /** What servers allow for a whole pack by default ({@code packs.maxPackSize}). */ + private static final long SERVER_PACK_LIMIT = 8L << 20; + private static final long MAX_FILE_SIZE = 256L << 20; + private static final int SNIFF_BYTES = 4096; + + /** + * @throws WorkshopException when the file cannot be used; the message is meant for the player + */ + public static ModelImport check(Path file) throws WorkshopException { + if (!Files.isRegularFile(file)) { + throw new WorkshopException("No file at " + file); + } + String name = file.getFileName().toString(); + String lower = name.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".obj")) { + int dot = lower.lastIndexOf('.'); + throw new WorkshopException("%s is not an OBJ file. Export the model from Blockbench or Blender as Wavefront OBJ (%s files cannot be read)." + .formatted(name, dot >= 0 ? lower.substring(dot) : "other")); + } + Mesh mesh; + long size; + try { + size = Files.size(file); + if (size > MAX_FILE_SIZE) { + throw new WorkshopException("%s is %d MB, more than the %d MB a model may be.".formatted(name, size >> 20, MAX_FILE_SIZE >> 20)); + } + if (isBinary(file)) { + throw new WorkshopException(name + " is not a text file, so it is not really an OBJ. Export it again as Wavefront OBJ."); + } + try (InputStream in = Files.newInputStream(file)) { + mesh = ObjLoader.load(name, in); + } + } catch (IOException e) { + throw new WorkshopException(e.getMessage() != null ? e.getMessage() : "Could not read " + name); + } + if (mesh.triangleCount() == 0) { + throw new WorkshopException(name + " has no faces, so there is nothing to draw."); + } + + float[] min = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE}; + float[] max = {-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE}; + boolean mapped = false; + for (MeshGroup group : mesh.groups()) { + float[] p = group.positions(); + for (int i = 0; i < p.length; i++) { + min[i % 3] = Math.min(min[i % 3], p[i]); + max[i % 3] = Math.max(max[i % 3], p[i]); + } + for (float uv : group.uvs()) { + // The loader flips V, so an unmapped corner reads 0 only in U. + mapped |= uv != 0.0F; + } + } + + List warnings = new ArrayList<>(); + if (!mesh.contains("$body")) { + warnings.add("No $body object: the whole model is drawn as one piece and nothing animates. See the modeling guide."); + } + if (!mapped) { + warnings.add("The model has no texture coordinates, so a texture will not show properly."); + } + float largest = Math.max(max[0] - min[0], Math.max(max[1] - min[1], max[2] - min[2])); + if (largest > 150.0F) { + warnings.add("The model is %d blocks long. One unit is one block; Blockbench models often need scaling by 1/16.".formatted(Math.round(largest))); + } else if (largest < 1.0F) { + warnings.add("The model is less than a block long. One unit is one block; check the export scale."); + } + if (size > SERVER_PACK_LIMIT) { + warnings.add("The file is %d MB. Servers allow 8 MB per pack unless their config says otherwise.".formatted(size >> 20)); + } + return new ModelImport(mesh, List.copyOf(warnings), min, max); + } + + private static boolean isBinary(Path file) throws IOException { + try (InputStream in = Files.newInputStream(file)) { + for (byte b : in.readNBytes(SNIFF_BYTES)) { + if (b == 0) { + return true; + } + } + } + return false; + } + + /** + * The texture that goes with a model: the PNG the {@code .mtl} next to it names, or one with the + * same name as the model. + * + * @return the file, or {@code null} if there is none + */ + public static @Nullable Path findTexture(Path obj) { + String name = obj.getFileName().toString(); + int dot = name.lastIndexOf('.'); + String base = dot > 0 ? name.substring(0, dot) : name; + Path folder = obj.toAbsolutePath().getParent(); + Path mtl = folder.resolve(base + ".mtl"); + if (Files.isRegularFile(mtl)) { + try { + for (String line : Files.readAllLines(mtl, StandardCharsets.ISO_8859_1)) { + String t = line.trim(); + if (t.startsWith("map_Kd ")) { + Path texture = folder.resolve(t.substring(7).trim()); + if (Files.isRegularFile(texture) && texture.toString().toLowerCase(Locale.ROOT).endsWith(".png")) { + return texture; + } + } + } + } catch (IOException | RuntimeException e) { + // A material file we cannot read only means looking for the texture by name. + } + } + Path sameName = folder.resolve(base + ".png"); + return Files.isRegularFile(sameName) ? sameName : null; + } + + /** + * The {@code $} objects a definition animates that the model does not have. Such a part is not + * an error, since it can also come as a separate model file, but usually it is a typo or an + * object the author forgot to name. + */ + public static List missingParts(AircraftDefinition def, Mesh mesh) { + Set wanted = new LinkedHashSet<>(); + if (def instanceof HelicopterDefinition heli) { + for (RotorDefinition rotor : heli.rotors) { + wanted.add(rotor.part()); + } + } else if (def instanceof PlaneDefinition plane) { + for (MovingPart part : plane.nozzles) { + wanted.add(part.part()); + } + for (WingPart wing : plane.wings) { + wanted.add(wing.part()); + for (MovingPart pylon : wing.pylons()) { + wanted.add(pylon.part()); + } + } + for (PropellerPart rotor : plane.propellers) { + wanted.add(rotor.part()); + for (PropellerPart.Blade blade : rotor.blades()) { + wanted.add(blade.part()); + } + } + } else if (def instanceof VehicleDefinition vehicle) { + for (VehiclePart part : vehicle.parts) { + wanted.add(part.part()); + } + } + def.hatches.forEach(p -> wanted.add(p.part())); + // The first canopy is registered under two names, and a model only needs one of them. + for (MovingPart canopy : def.canopies) { + if (!canopy.part().equals("canopy")) { + wanted.add(canopy.part()); + } + } + for (WeaponBayPart bay : def.weaponBays) { + wanted.add(bay.door().part()); + } + def.landingGear.forEach((LandingGearPart p) -> wanted.add(p.part())); + def.throttleParts.forEach((ThrottlePart p) -> wanted.add(p.part())); + def.rotatingParts.forEach((RotatingPart p) -> wanted.add(p.part())); + def.cameraParts.forEach((CameraPart p) -> wanted.add(p.part())); + def.wheels.forEach((WheelPart p) -> wanted.add(p.part())); + def.steeringWheels.forEach((WheelPart p) -> wanted.add(p.part())); + def.trackRollers.forEach((TrackRoller p) -> wanted.add(p.part())); + def.crawlerTracks.forEach((CrawlerTrack p) -> wanted.add(p.part())); + def.weaponParts.forEach((WeaponPart p) -> wanted.add(p.part())); + + List missing = new ArrayList<>(); + for (String part : wanted) { + String object = "$" + part; + boolean found = mesh.contains(object) || part.equals("canopy0") && mesh.contains("$canopy"); + if (!found) { + missing.add(object); + } + } + return missing; + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/PartField.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/PartField.java new file mode 100644 index 00000000..ca77e783 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/PartField.java @@ -0,0 +1,69 @@ +package com.jnhprojects.mcheli.workshop; + +/** + * One value in a part's line, for example the position in {@code AddSeat = x, y, z}. + * + * @param id names the field in the language files, {@code gui.mcheli.workshop.field.} + * @param kind how it is edited + * @param index where it starts among the line's comma separated values + * @param fallback the value used until it is set, the same the definition reader falls back to. + * Three comma separated numbers for a vector. + */ +public record PartField(String id, Kind kind, int index, String fallback) { + public enum Kind { + NUMBER(1), + WHOLE_NUMBER(1), + BOOLEAN(1), + /** A loaded weapon's name. */ + WEAPON(1), + /** A point on the vehicle, which gets a gizmo. */ + POSITION(3), + /** A direction or distance, three numbers with no gizmo of their own. */ + VECTOR(3), + /** Every value from here to the end of the line, edited as text. */ + REST(1); + + private final int width; + + Kind(int width) { + this.width = width; + } + + /** How many comma separated values the field takes up. */ + public int width() { + return width; + } + } + + static PartField position(int index) { + return new PartField("position", Kind.POSITION, index, "0, 0, 0"); + } + + static PartField position(String id, int index) { + return new PartField(id, Kind.POSITION, index, "0, 0, 0"); + } + + static PartField vector(String id, int index, String fallback) { + return new PartField(id, Kind.VECTOR, index, fallback); + } + + static PartField number(String id, int index, String fallback) { + return new PartField(id, Kind.NUMBER, index, fallback); + } + + static PartField whole(String id, int index, String fallback) { + return new PartField(id, Kind.WHOLE_NUMBER, index, fallback); + } + + static PartField bool(String id, int index, String fallback) { + return new PartField(id, Kind.BOOLEAN, index, fallback); + } + + static PartField weapon(int index) { + return new PartField("weapon", Kind.WEAPON, index, "none"); + } + + static PartField rest(String id, int index, String fallback) { + return new PartField(id, Kind.REST, index, fallback); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/PartType.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/PartType.java new file mode 100644 index 00000000..370187cc --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/PartType.java @@ -0,0 +1,245 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.DefinitionValues; +import org.jspecify.annotations.Nullable; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; + +import static com.jnhprojects.mcheli.workshop.PartField.bool; +import static com.jnhprojects.mcheli.workshop.PartField.number; +import static com.jnhprojects.mcheli.workshop.PartField.position; +import static com.jnhprojects.mcheli.workshop.PartField.rest; +import static com.jnhprojects.mcheli.workshop.PartField.vector; +import static com.jnhprojects.mcheli.workshop.PartField.weapon; +import static com.jnhprojects.mcheli.workshop.PartField.whole; + +/** + * Every line the workshop can place, with the layout of its values. Each entry mirrors a parser in + * {@code AircraftDefinition} or its subclasses: the field indices are the ones that parser reads, + * the lengths are the value counts it accepts, and the first values of a new line are its fallback. + */ +public enum PartType { + SEAT(Group.SEATS, "AddSeat", all(), lengths(3, 4), position(0), bool("rotating_seat", 3, "false")), + GUNNER_SEAT(Group.SEATS, "AddGunnerSeat", all(), lengths(3, 6, 7, 9, 10), + position(0), position("camera", 3), bool("switch_gunner", 6, "false"), + number("min_pitch", 7, "-30"), number("max_pitch", 8, "70"), bool("rotating_seat", 9, "false")), + FIXED_SEAT(Group.SEATS, "AddFixRotSeat", all(), lengths(3, 6, 7, 9, 10), + position(0), position("camera", 3), bool("switch_gunner", 6, "false"), + number("yaw", 7, "0"), number("pitch", 8, "0"), bool("rotating_seat", 9, "false")), + + CAMERA(Group.CAMERAS, "CameraPosition", all(), lengths(3, 4, 5, 6), + position(0), bool("always", 3, "false"), number("yaw", 4, "0"), number("pitch", 5, "0")), + CAMERA_TURRET(Group.CAMERAS, "AddPartCamera", all(), lengths(3, 4, 5), + position(0), bool("yaw_sync", 3, "true"), bool("pitch_sync", 4, "false")), + + ROTOR(Group.ROTORS, "AddRotor", only(WorkshopKind.HELICOPTER), lengths(8, 9), + whole("blades", 0, "2"), whole("blade_step", 1, "180"), position(2), vector("axis", 5, "0, 1, 0"), bool("foldable", 8, "false")), + PROPELLER(Group.ROTORS, "AddPartRotor", only(WorkshopKind.PLANE), lengths(6, 7), + position(0), vector("axis", 3, "0, 0, 1"), number("max_angle", 6, "90")), + BLADE(Group.ROTORS, "AddBlade", only(WorkshopKind.PLANE), PROPELLER, lengths(8), + whole("blades", 0, "2"), whole("blade_step", 1, "180"), position(2), vector("axis", 5, "0, 0, 1")), + + WING(Group.WINGS, "AddPartWing", only(WorkshopKind.PLANE), lengths(7), + position(0), vector("axis", 3, "0, 1, 0"), number("max_angle", 6, "90")), + PYLON(Group.WINGS, "AddPartPylon", only(WorkshopKind.PLANE), WING, lengths(7), + position(0), vector("axis", 3, "0, 1, 0"), number("max_angle", 6, "90")), + /** The format has no flaps of its own. A flap is a part that turns with the throttle. */ + FLAP(Group.WINGS, "AddPartThrottle", all(), lengths(7, 10), + position(0), vector("axis", 3, "1, 0, 0"), number("angle", 6, "-30"), vector("slide", 7, "0, 0, 0")), + NOZZLE(Group.WINGS, "AddPartNozzle", only(WorkshopKind.PLANE), lengths(6), position(0), vector("axis", 3, "1, 0, 0")), + + WHEEL(Group.WHEELS, "AddPartWheel", all(), lengths(3, 4, 7, 10), + position(0), number("steer", 3, "0"), vector("axis", 4, "0, 1, 0"), position("pivot", 7)), + STEERING_WHEEL(Group.WHEELS, "AddPartSteeringWheel", all(), lengths(7), + position(0), vector("axis", 3, "0, 0, 1"), number("angle", 6, "90")), + /** One line naming every probe, so its first position stands for all of them. */ + GROUND_WHEELS(Group.WHEELS, "SetWheelPos", only(WorkshopKind.TANK), null, true, null, + new PartField("position", PartField.Kind.POSITION, 0, "1.5, -0.24, 2"), rest("more_z", 3, "-2")), + + TRACK_ROLLER(Group.TRACKS, "AddTrackRoller", only(WorkshopKind.TANK), lengths(3), position(0)), + CRAWLER_TRACK(Group.TRACKS, "AddCrawlerTrack", only(WorkshopKind.TANK), null, + bool("reverse", 0, "false"), number("link_spacing", 1, "0.5"), number("x", 2, "1.2"), + rest("outline", 3, "0.2/2, 1/2, 1/-2, 0.2/-2")), + + LANDING_GEAR(Group.LANDING_GEAR, "AddPartLG", all(), lengths(6, 7, 10, 11), + position(0), vector("axis", 3, "1, 0, 0"), number("max_angle", 6, "90"), + vector("second_axis", 7, "0, 0, 0"), number("second_angle", 10, "90")), + LANDING_GEAR_REVERSED(Group.LANDING_GEAR, "AddPartLGRev", all(), lengths(6, 7, 10, 11), + position(0), vector("axis", 3, "1, 0, 0"), number("max_angle", 6, "90"), + vector("second_axis", 7, "0, 0, 0"), number("second_angle", 10, "90")), + LANDING_GEAR_HATCH(Group.LANDING_GEAR, "AddPartLGHatch", all(), lengths(6, 7, 10, 11), + position(0), vector("axis", 3, "1, 0, 0"), number("max_angle", 6, "90"), + vector("second_axis", 7, "0, 0, 0"), number("second_angle", 10, "90")), + LANDING_GEAR_SLIDING(Group.LANDING_GEAR, "AddPartSlideRotLG", all(), lengths(9, 10), + vector("slide", 0, "0, 0, 0"), position(3), vector("axis", 6, "1, 0, 0"), number("max_angle", 9, "90")), + + CANOPY(Group.CANOPIES, "AddPartCanopy", all(), lengths(6, 7), + position(0), vector("axis", 3, "1, 0, 0"), number("max_angle", 6, "90")), + /** Sliding parts have no pivot: their three numbers are how far they travel. */ + CANOPY_SLIDING(Group.CANOPIES, "AddPartSlideCanopy", all(), lengths(3), vector("slide", 0, "0, 0, -1")), + HATCH(Group.CANOPIES, "AddPartHatch", all(), lengths(6, 7), + position(0), vector("axis", 3, "1, 0, 0"), number("max_angle", 6, "90")), + HATCH_SLIDING(Group.CANOPIES, "AddPartSlideHatch", all(), lengths(3), vector("slide", 0, "0, 0, -1")), + + WEAPON(Group.WEAPONS, "AddWeapon", all(), lengths(4, 5, 6, 7, 8, 9, 10, 11, 12, 13), + weapon(0), position(1), number("yaw", 4, "0"), number("pitch", 5, "0"), + bool("pilot", 6, "true"), whole("seat", 7, "1"), number("default_yaw", 8, "0"), + number("min_yaw", 9, "0"), number("max_yaw", 10, "0"), number("min_pitch", 11, "0"), number("max_pitch", 12, "0")), + TURRET_WEAPON(Group.WEAPONS, "AddTurretWeapon", all(), lengths(4, 5, 6, 7, 8, 9, 10, 11, 12, 13), + weapon(0), position(1), number("yaw", 4, "0"), number("pitch", 5, "0"), + bool("pilot", 6, "true"), whole("seat", 7, "1"), number("default_yaw", 8, "0"), + number("min_yaw", 9, "0"), number("max_yaw", 10, "0"), number("min_pitch", 11, "0"), number("max_pitch", 12, "0")), + TURRET(Group.WEAPONS, "TurretPosition", all(), null, true, lengths(3), position(0)), + + HIT_BOX(Group.HIT_BOXES, "BoundingBox", all(), lengths(5, 6), + position(0), number("width", 3, "2"), number("height", 4, "2"), number("damage_factor", 5, "1")), + + ENGINE(Group.ENGINE, "EnginePosition", all(), null, true, lengths(3), position(0)), + FUEL_TANK(Group.ENGINE, "FuelTankPosition", all(), null, true, lengths(3), position(0)); + + /** The headings of the parts list. */ + public enum Group { + SEATS, CAMERAS, ROTORS, WINGS, WHEELS, TRACKS, LANDING_GEAR, CANOPIES, WEAPONS, HIT_BOXES, ENGINE + } + + private static final Map BY_KEY = new HashMap<>(); + + static { + for (PartType type : values()) { + BY_KEY.put(DefinitionValues.lower(type.key), type); + } + } + + private final Group group; + private final String key; + private final Set kinds; + private final @Nullable PartType parent; + private final boolean single; + private final int @Nullable [] lengths; + private final List fields; + + PartType(Group group, String key, Set kinds, int @Nullable [] lengths, PartField... fields) { + this(group, key, kinds, null, false, lengths, fields); + } + + PartType(Group group, String key, Set kinds, @Nullable PartType parent, int @Nullable [] lengths, PartField... fields) { + this(group, key, kinds, parent, false, lengths, fields); + } + + PartType(Group group, String key, Set kinds, @Nullable PartType parent, boolean single, int @Nullable [] lengths, PartField... fields) { + this.group = group; + this.key = key; + this.kinds = kinds; + this.parent = parent; + this.single = single; + this.lengths = lengths; + this.fields = List.of(fields); + } + + private static Set all() { + return EnumSet.allOf(WorkshopKind.class); + } + + private static Set only(WorkshopKind kind) { + return EnumSet.of(kind); + } + + private static int[] lengths(int... lengths) { + return lengths; + } + + public Group group() { + return group; + } + + public String key() { + return key; + } + + /** Names the part in the language files, {@code gui.mcheli.workshop.part.}. */ + public String id() { + return name().toLowerCase(Locale.ROOT); + } + + public boolean appliesTo(WorkshopKind kind) { + return kinds.contains(kind); + } + + /** The part a line of this type belongs to, which it has to follow in the file. */ + public @Nullable PartType parent() { + return parent; + } + + /** Whether a definition has at most one such line. */ + public boolean isSingle() { + return single; + } + + public List fields() { + return fields; + } + + /** The field a gizmo moves first and the list goes to, or {@code null} for a part with no spot of its own. */ + public @Nullable PartField mainPosition() { + for (PartField field : fields) { + if (field.kind() == PartField.Kind.POSITION) { + return field; + } + } + return null; + } + + /** The values of a new line, with only the ones its parser requires. */ + public String defaultValue() { + StringJoiner out = new StringJoiner(", "); + for (PartField field : fields) { + if (lengths == null || field.index() < lengths[0]) { + out.add(field.fallback()); + } + } + return out.toString(); + } + + /** + * The shortest value count that keeps every value differing from its fallback, never shorter + * than {@code current}, since a parser can read meaning into a line's length alone. A parser + * only takes certain lengths: those are in {@link #lengths}. + */ + int lengthFor(String[] tokens, int current) { + if (lengths == null) { + return tokens.length; + } + int needed = current; + for (PartField field : fields) { + for (int i = 0; i < field.kind().width(); i++) { + int at = field.index() + i; + if (at < tokens.length && tokens[at] != null && at >= needed && !tokens[at].equals(fallback(field, i))) { + needed = at + 1; + } + } + } + for (int length : lengths) { + if (length >= needed) { + return length; + } + } + return lengths[lengths.length - 1]; + } + + /** One value of a field's fallback. */ + static String fallback(PartField field, int component) { + String[] values = DefinitionValues.split(field.fallback()); + return component < values.length ? values[component] : ""; + } + + /** The type of a key in a definition file, or {@code null} if the workshop does not place it. */ + public static @Nullable PartType byKey(String key) { + return BY_KEY.get(DefinitionValues.lower(key)); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/TuningKey.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/TuningKey.java new file mode 100644 index 00000000..3ce36b01 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/TuningKey.java @@ -0,0 +1,109 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Locale; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * A flight, drive or durability value on the workshop's tuning page: a single value key of the + * definition. Until the file sets it, the page shows what the game would use, read from a blank + * definition of the kind, so the defaults here cannot drift from the real ones. + * + * @param choices the values a choice key takes, or empty for a number or a switch + * @param min the lowest number the parser accepts + * @param max the highest number the parser accepts + */ +public record TuningKey(String key, Type type, List choices, double min, double max, + Predicate applies, Function current) { + public enum Type { + NUMBER, SWITCH, CHOICE + } + + public static final List ALL = List.of( + number("MaxHp", 1, 100000, d -> d.maxHp), + number("Speed", 0, 1.8, d -> d.speed), + number("MotionFactor", 0, 1, d -> d.motionFactor), + number("Gravity", -50, 50, d -> d.gravity), + number("GravityInWater", -50, 50, d -> d.gravityInWater), + number("ThrottleUpDown", 0, 3, d -> d.throttleUpDown), + number("MobilityYaw", 0, 100, d -> d.mobilityYaw), + number("MobilityPitch", 0, 100, d -> d.mobilityPitch), + number("MobilityRoll", 0, 100, d -> d.mobilityRoll), + number("MobilityYawOnGround", 0, 100, d -> d.mobilityYawOnGround), + number("RotorSpeed", -10000, 10000, d -> d.rotorSpeed), + number("MaxFuel", 0, 100000000, d -> d.maxFuel), + number("FuelConsumption", 0, 10000, d -> d.fuelConsumption), + number("DamageFactor", 0, 1, d -> d.damageFactor), + number("ArmorDamageFactor", 0, 10000, d -> d.armorDamageFactor), + number("ArmorMinDamage", 0, 1000000, d -> d.armorMinDamage), + number("Width", 0.1, 1000, d -> d.bodyWidth), + number("Height", 0.1, 1000, d -> d.bodyHeight), + number("StepHeight", 0, 1000, d -> d.stepHeight), + number("OnGroundPitch", -90, 90, d -> -d.onGroundPitch), + number("InventorySize", 0, 54, d -> d.inventorySize), + bool("Float", d -> d.floats), + bool("EnableBack", d -> d.enableBack), + bool("Regeneration", d -> d.regeneration), + bool("EnableGunnerMode", d -> d.gunnerMode), + bool("EnableNightVision", d -> d.nightVision), + bool("EnableEjectionSeat", d -> d.ejectionSeat), + kindBool("EnableFoldBlade", HelicopterDefinition.class, d -> ((HelicopterDefinition) d).foldableBlades), + kindBool("EnableVtol", PlaneDefinition.class, d -> ((PlaneDefinition) d).vtol), + kindBool("VariableSweepWing", PlaneDefinition.class, d -> ((PlaneDefinition) d).variableSweepWing), + kindNumber("VtolYaw", 0, 1, PlaneDefinition.class, d -> ((PlaneDefinition) d).vtolYaw), + kindNumber("VtolPitch", 0.01, 1, PlaneDefinition.class, d -> ((PlaneDefinition) d).vtolPitch), + kindNumber("SweepWingSpeed", 0, 5, PlaneDefinition.class, d -> ((PlaneDefinition) d).sweepWingSpeed), + kindNumber("PivotTurnThrottle", 0, 1, TankDefinition.class, d -> d.pivotTurnThrottle), + kindNumber("OnGroundPitchFactor", 0, 180, TankDefinition.class, d -> d.onGroundPitchFactor), + kindNumber("OnGroundRollFactor", 0, 180, TankDefinition.class, d -> d.onGroundRollFactor), + kindNumber("WeightedCenterZ", -1000, 1000, TankDefinition.class, d -> ((TankDefinition) d).weightedCenterZ), + new TuningKey("WeightType", Type.CHOICE, List.of("unknown", "car", "tank"), 0, 0, TankDefinition.class::isInstance, + d -> ((TankDefinition) d).weightType.name().toLowerCase(Locale.ROOT))); + + private static TuningKey number(String key, double min, double max, Function read) { + return new TuningKey(key, Type.NUMBER, List.of(), min, max, d -> true, d -> WorkshopPart.number(read.apply(d).doubleValue())); + } + + private static TuningKey kindNumber(String key, double min, double max, Class kind, + Function read) { + return new TuningKey(key, Type.NUMBER, List.of(), min, max, kind::isInstance, d -> WorkshopPart.number(read.apply(d).doubleValue())); + } + + private static TuningKey bool(String key, Predicate read) { + return new TuningKey(key, Type.SWITCH, List.of(), 0, 0, d -> true, d -> String.valueOf(read.test(d))); + } + + private static TuningKey kindBool(String key, Class kind, Predicate read) { + return new TuningKey(key, Type.SWITCH, List.of(), 0, 0, kind::isInstance, d -> String.valueOf(read.test(d))); + } + + /** Names the value in the language files, {@code gui.mcheli.workshop.tuning.}. */ + public String id() { + return key.toLowerCase(Locale.ROOT); + } + + /** The default of this key for a vehicle kind. */ + public String defaultFor(WorkshopKind kind) { + return current.apply(kind.create("default")); + } + + /** Whether a number typed in is one the definition keeps as it is, not clamped or rejected. */ + public boolean accepts(@Nullable String text) { + if (text == null || type != Type.NUMBER) { + return false; + } + try { + double value = Double.parseDouble(text.trim()); + return value >= min && value <= max; + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopCamera.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopCamera.java new file mode 100644 index 00000000..65043a17 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopCamera.java @@ -0,0 +1,90 @@ +package com.jnhprojects.mcheli.workshop; + +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * The orthographic view around the model in the workshop. + * + *

In the frame the game draws GUI entities in, X points right, Y points down and Z points away + * from the viewer, with one unit being {@link #scale()} GUI pixels. {@link #rotation()} and + * {@link #translation()} bring a point of the model into that frame, and {@link #project} says where + * it lands, so the gizmos drawn on top line up with the vehicle drawn underneath. + */ +public final class WorkshopCamera { + private static final float MIN_SCALE = 1.0F; + private static final float MAX_SCALE = 400.0F; + /** How much of the view a framed model takes up, leaving room around it for gizmos. */ + private static final float FRAME_FILL = 0.85F; + private static final float DEGREES_PER_PIXEL = 0.6F; + private static final float DEFAULT_YAW = 215.0F; + private static final float DEFAULT_PITCH = 25.0F; + + private float yaw = DEFAULT_YAW; + private float pitch = DEFAULT_PITCH; + private float scale = 20.0F; + private final Vector3f target = new Vector3f(); + + /** GUI pixels per block. */ + public float scale() { + return scale; + } + + public void orbit(double dx, double dy) { + yaw += (float) dx * DEGREES_PER_PIXEL; + pitch = Math.clamp(pitch + (float) dy * DEGREES_PER_PIXEL, -89.0F, 89.0F); + } + + /** Moves the view so the point under the mouse stays under the mouse. */ + public void pan(double dx, double dy) { + Vector3f move = unproject(dx, dy); + target.sub(move); + } + + /** Zooms by a factor. */ + public void zoom(double factor) { + scale = Math.clamp((float) (scale * factor), MIN_SCALE, MAX_SCALE); + } + + /** Looks at the middle of a box, from the starting angle, with all of it in a view of the given size. */ + public void frame(float[] min, float[] max, int width, int height) { + yaw = DEFAULT_YAW; + pitch = DEFAULT_PITCH; + target.set((min[0] + max[0]) / 2.0F, (min[1] + max[1]) / 2.0F, (min[2] + max[2]) / 2.0F); + scale = 1.0F; + float reachX = 0.1F; + float reachY = 0.1F; + for (int corner = 0; corner < 8; corner++) { + Vector3f p = project((corner & 1) == 0 ? min[0] : max[0], (corner & 2) == 0 ? min[1] : max[1], (corner & 4) == 0 ? min[2] : max[2]); + reachX = Math.max(reachX, Math.abs(p.x)); + reachY = Math.max(reachY, Math.abs(p.y)); + } + scale = Math.clamp(FRAME_FILL * Math.min(width / 2.0F / reachX, height / 2.0F / reachY), MIN_SCALE, MAX_SCALE); + } + + public Quaternionf rotation() { + return new Quaternionf().rotationZ((float) Math.PI) + .rotateX((float) Math.toRadians(-pitch)) + .rotateY((float) Math.toRadians(yaw)); + } + + /** What to move the rotated model by, so the target lands in the middle of the view. */ + public Vector3f translation() { + return rotation().transform(new Vector3f(target)).negate(); + } + + /** + * Where a point of the model lands. + * + * @return GUI pixels from the middle of the view in X and Y, and blocks away from the viewer in Z + */ + public Vector3f project(double x, double y, double z) { + Vector3f v = rotation().transform(new Vector3f((float) x, (float) y, (float) z).sub(target)); + return new Vector3f(v.x * scale, v.y * scale, v.z); + } + + /** The move in the model that shows as a move of so many GUI pixels, staying at the same depth. */ + public Vector3f unproject(double dx, double dy) { + return rotation().conjugate().transform(new Vector3f((float) dx / scale, (float) dy / scale, 0.0F)); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopException.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopException.java new file mode 100644 index 00000000..93ce6ffd --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopException.java @@ -0,0 +1,8 @@ +package com.jnhprojects.mcheli.workshop; + +/** Something the workshop cannot do, with a message for the player to read. */ +public class WorkshopException extends Exception { + public WorkshopException(String message) { + super(message); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopKind.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopKind.java new file mode 100644 index 00000000..12cc2154 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopKind.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; + +/** The kinds of vehicle the workshop can build, with the folder each keeps its files in. */ +public enum WorkshopKind { + HELICOPTER("helicopter", HelicopterDefinition.FOLDER, HelicopterDefinition::new), + PLANE("plane", PlaneDefinition.FOLDER, PlaneDefinition::new), + TANK("tank", TankDefinition.FOLDER, TankDefinition::new), + VEHICLE("vehicle", VehicleDefinition.FOLDER, VehicleDefinition::new); + + private final String id; + private final String folder; + private final Function factory; + + WorkshopKind(String id, String folder, Function factory) { + this.id = id; + this.folder = folder; + this.factory = factory; + } + + public String id() { + return id; + } + + public String folder() { + return folder; + } + + /** A definition with nothing read into it yet, holding the defaults of this kind. */ + public AircraftDefinition create(String name) { + return factory.apply(name); + } + + public static @Nullable WorkshopKind byFolder(String folder) { + for (WorkshopKind kind : values()) { + if (kind.folder.equals(folder)) { + return kind; + } + } + return null; + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopPart.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopPart.java new file mode 100644 index 00000000..66392570 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopPart.java @@ -0,0 +1,107 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.DefinitionValues; +import com.jnhprojects.mcheli.content.Vector3; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.StringJoiner; + +/** + * One placed part: a line of the definition, read through its {@link PartType}. Changing a value + * gives the new text of the line; values the change does not touch keep the text they had. + * + * @param line the line in the document + * @param key the key as the file spells it + * @param value the line's values + */ +public record WorkshopPart(PartType type, int line, String key, String value) { + private static final int DECIMALS = 4; + + private String[] tokens() { + return DefinitionValues.split(value); + } + + /** One value of a field, or its fallback where the line stops short. */ + public String get(PartField field, int component) { + String[] tokens = tokens(); + if (field.kind() == PartField.Kind.REST) { + return field.index() < tokens.length + ? String.join(", ", Arrays.copyOfRange(tokens, field.index(), tokens.length)) + : field.fallback(); + } + int at = field.index() + component; + return at < tokens.length ? tokens[at] : PartType.fallback(field, component); + } + + /** A field's three numbers, with anything that is not a number read as zero. */ + public Vector3 vector(PartField field) { + return new Vector3(numberAt(field, 0), numberAt(field, 1), numberAt(field, 2)); + } + + private double numberAt(PartField field, int component) { + try { + return Double.parseDouble(get(field, component).trim()); + } catch (NumberFormatException e) { + return 0.0; + } + } + + /** The line's values with one value of a field changed. */ + public String with(PartField field, int component, String text) { + String[] old = tokens(); + if (field.kind() == PartField.Kind.REST) { + String[] kept = Arrays.copyOf(old, Math.max(field.index(), 0)); + fillGaps(kept, old.length); + StringJoiner out = new StringJoiner(", "); + for (String token : kept) { + out.add(token); + } + out.add(text.trim()); + return out.toString(); + } + int at = field.index() + component; + String[] tokens = Arrays.copyOf(old, Math.max(old.length, at + 1)); + fillGaps(tokens, old.length); + tokens[at] = text.trim(); + return join(tokens, old.length); + } + + /** The line's values with a field's three numbers changed. */ + public String with(PartField field, Vector3 v) { + String[] old = tokens(); + int at = field.index(); + String[] tokens = Arrays.copyOf(old, Math.max(old.length, at + 3)); + fillGaps(tokens, old.length); + tokens[at] = number(v.x()); + tokens[at + 1] = number(v.y()); + tokens[at + 2] = number(v.z()); + return join(tokens, old.length); + } + + /** Where a change reaches past the end of the line, the values in between take their fallbacks. */ + private void fillGaps(String[] tokens, int from) { + for (PartField field : type.fields()) { + for (int i = 0; i < field.kind().width(); i++) { + int at = field.index() + i; + if (at >= from && at < tokens.length && tokens[at] == null) { + tokens[at] = PartType.fallback(field, i); + } + } + } + } + + private String join(String[] tokens, int current) { + int length = type.lengthFor(tokens, current); + String[] kept = Arrays.copyOf(tokens, Math.max(length, 0)); + fillGaps(kept, current); + return String.join(", ", kept); + } + + /** A number as it goes into a file: at most four decimals and no trailing zeros. */ + public static String number(double value) { + BigDecimal rounded = BigDecimal.valueOf(value).setScale(DECIMALS, RoundingMode.HALF_UP).stripTrailingZeros(); + return rounded.signum() == 0 ? "0" : rounded.toPlainString(); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java new file mode 100644 index 00000000..916c13aa --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java @@ -0,0 +1,444 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.DefinitionReader; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PackFolder; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +/** + * One vehicle open in the workshop: a definition file and a model in a content pack. + * + *

What the workshop edits and saves is an ordinary pack folder in {@code mcheli/packs}, so the + * game loads it like any other, a server sends it to its players, and anything written by hand + * can be opened and changed further. A pack that is a zip is never touched: opening one makes a + * folder copy with {@value #COPY_SUFFIX} added to its id, whose definitions win over the original's + * because packs load in id order. Zips also stay open while in use, and overwriting one could not + * work on Windows. + */ +public final class WorkshopProject { + /** Added to the id of a zip pack's folder copy. */ + public static final String COPY_SUFFIX = "-edit"; + + private static final Pattern VEHICLE_NAME = Pattern.compile("[a-z0-9][a-z0-9_-]{0,63}"); + private static final Pattern FILE_SAFE = Pattern.compile("[^a-zA-Z0-9._-]"); + private static final int PLAIN_TEXTURE_GREY = 0xB0; + + private final Path root; + private final WorkshopKind kind; + private final String vehicle; + private final DefinitionDocument document; + private PackManifest manifest; + private final ModelImport model; + private AircraftDefinition definition; + private List problems = List.of(); + private List saved; + + /** A vehicle a pack holds, as the workshop lists it. */ + public record Entry(String pack, Path source, boolean zipped, WorkshopKind kind, String vehicle) { + } + + private WorkshopProject(Path root, PackManifest manifest, WorkshopKind kind, String vehicle, String text, ModelImport model) { + this.root = root; + this.manifest = manifest; + this.kind = kind; + this.vehicle = vehicle; + this.document = new DefinitionDocument(text); + this.model = model; + this.saved = document.snapshot(); + this.definition = kind.create(vehicle); + refresh(); + } + + /** + * Starts a vehicle: makes the pack folder, takes in the model and its texture and writes a first + * definition with a pilot seat, so there is something to fly from the start. + * + * @param packs {@code mcheli/packs} + * @param objFile the model to take in + * @param texture its texture, or {@code null} to start with plain grey + * @throws WorkshopException when something is wrong with the names or the model + */ + public static WorkshopProject create(Path packs, String packId, WorkshopKind kind, String vehicle, String author, + Path objFile, @Nullable Path texture) throws WorkshopException { + if (!PackManifest.isValidId(packId)) { + throw new WorkshopException("A pack id is 1 to 64 lower case letters, digits, '-', '_' and '.'; \"%s\" will not do.".formatted(packId)); + } + if (!VEHICLE_NAME.matcher(vehicle).matches()) { + throw new WorkshopException("A vehicle name is 1 to 64 lower case letters, digits, '-' and '_'; \"%s\" will not do.".formatted(vehicle)); + } + Path root = packs.resolve(packId); + if (Files.exists(root) || Files.exists(packs.resolve(packId + ".zip"))) { + throw new WorkshopException("There is already a pack called %s. Open it from the list or pick another id.".formatted(packId)); + } + ModelImport model = ModelImport.check(objFile); + + PackManifest manifest = new PackManifest(packId, vehicle, author, "1.0.0", ""); + String text = "// Made with the MCHeli Rework workshop. The keys are explained in docs/content/aircraft.md.\n" + + "DisplayName = " + vehicle + "\n" + + "AddSeat = 0, 1, 0\n"; + try { + Path assets = root.resolve("assets/mcheli"); + Path modelFile = assets.resolve("models").resolve(kind.folder()).resolve(vehicle + ".obj"); + Path textureFile = assets.resolve("textures").resolve(kind.folder()).resolve(vehicle + ".png"); + Files.createDirectories(modelFile.getParent()); + Files.createDirectories(textureFile.getParent()); + Files.copy(objFile, modelFile); + if (texture != null) { + Files.copy(texture, textureFile); + } else { + Files.write(textureFile, plainTexture()); + } + WorkshopProject project = new WorkshopProject(root, manifest, kind, vehicle, text, model); + project.write(); + return project; + } catch (IOException e) { + throw new WorkshopException("Could not write the pack: " + e); + } + } + + /** + * Opens a vehicle from the list. A vehicle in a zip pack opens from a folder copy, made the first + * time and reused after that. + * + * @param packs {@code mcheli/packs} + */ + public static WorkshopProject open(Path packs, Entry entry) throws WorkshopException { + try { + Path root = entry.source(); + String id = entry.pack(); + if (entry.zipped()) { + id = id + COPY_SUFFIX; + root = packs.resolve(id); + if (!Files.exists(root)) { + unpack(entry.source(), root, id); + } + } + PackManifest manifest; + try (ContentPack pack = ContentPack.open(root)) { + manifest = pack.manifest(); + } + Path definitionFile = root.resolve("assets/mcheli").resolve(entry.kind().folder()).resolve(entry.vehicle() + ".txt"); + String text = DefinitionReader.decode(Files.readAllBytes(definitionFile)); + Path modelFile = root.resolve("assets/mcheli/models").resolve(entry.kind().folder()).resolve(entry.vehicle() + ".obj"); + if (!Files.isRegularFile(modelFile)) { + throw new WorkshopException("%s has no OBJ model in its pack. The workshop only edits OBJ models; an MQO has to be converted first.".formatted(entry.vehicle())); + } + return new WorkshopProject(root, manifest, entry.kind(), entry.vehicle(), text, ModelImport.check(modelFile)); + } catch (IOException e) { + throw new WorkshopException("Could not open %s: %s".formatted(entry.vehicle(), e)); + } catch (PackException e) { + throw new WorkshopException(entry.pack() + ": " + e.getMessage()); + } + } + + /** Every vehicle in {@code mcheli/packs}, by pack and name. Packs that cannot be read are left out. */ + public static List list(Path packs) { + List entries = new ArrayList<>(); + PackFolder.Scan scan = PackFolder.scan(packs); + for (ContentPack pack : scan.packs()) { + try (pack) { + boolean zipped = !Files.isDirectory(pack.source()); + for (WorkshopKind kind : WorkshopKind.values()) { + String prefix = "assets/mcheli/" + kind.folder() + "/"; + for (String file : pack.files()) { + if (file.startsWith(prefix) && file.endsWith(".txt") && file.indexOf('/', prefix.length()) < 0) { + entries.add(new Entry(pack.id(), pack.source(), zipped, kind, + file.substring(prefix.length(), file.length() - 4))); + } + } + } + } catch (IOException e) { + // Listing only reads names, so a pack that will not close costs nothing here. + } + } + entries.sort(Comparator.comparing(Entry::pack).thenComparing(Entry::vehicle)); + return entries; + } + + private static void unpack(Path zip, Path to, String id) throws IOException, PackException { + Path temp = to.resolveSibling(to.getFileName() + ".tmp"); + deleteTree(temp); + try (ContentPack pack = ContentPack.open(zip)) { + Path top = pack.assets().getParent().getParent(); + for (String file : pack.files()) { + Path target = temp.resolve(file); + Files.createDirectories(target.getParent()); + try (InputStream in = Files.newInputStream(top.resolve(file))) { + Files.copy(in, target); + } + } + PackManifest old = pack.manifest(); + PackManifest copy = new PackManifest(id, old.name(), old.author(), old.version(), old.description()); + Files.writeString(temp.resolve(PackManifest.FILE), copy.text()); + } + Files.move(temp, to); + } + + private static void deleteTree(Path folder) throws IOException { + if (!Files.exists(folder)) { + return; + } + try (Stream walk = Files.walk(folder)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + + /** The pack folder. */ + public Path root() { + return root; + } + + /** The pack's {@code assets/mcheli} folder, for the runtime assets. */ + public Path assets() { + return root.resolve("assets/mcheli"); + } + + public WorkshopKind kind() { + return kind; + } + + public String vehicle() { + return vehicle; + } + + public DefinitionDocument document() { + return document; + } + + public PackManifest manifest() { + return manifest; + } + + public void setManifest(PackManifest manifest) { + this.manifest = manifest; + } + + public ModelImport model() { + return model; + } + + /** The definition as the game would read it now. After an edit it could not use, the last good one. */ + public AircraftDefinition definition() { + return definition; + } + + /** What is wrong with the definition as it stands; empty when the game takes it without complaint. */ + public List problems() { + return problems; + } + + /** Reads the document again. Call after every edit. */ + public void refresh() { + List found = new ArrayList<>(); + try { + DefinitionReader.Result result = DefinitionReader.read(document.text(), kind.create(vehicle)); + found.addAll(result.problems()); + if (result.isValid()) { + definition = result.definition(); + for (String part : ModelImport.missingParts(definition, model.mesh())) { + found.add("the model has no " + part + " object, so that part will not show"); + } + } + } catch (IOException e) { + found.add("could not read the definition: " + e); + } + problems = List.copyOf(found); + } + + /** Parts of a definition, in file order, for this vehicle kind. */ + public List parts() { + List parts = new ArrayList<>(); + for (DefinitionDocument.Entry entry : document.entries()) { + PartType type = PartType.byKey(entry.key()); + if (type != null && type.appliesTo(kind)) { + parts.add(new WorkshopPart(type, entry.line(), entry.key(), entry.value())); + } + } + return parts; + } + + public @Nullable WorkshopPart partAt(int line) { + if (line < 0 || line >= document.size()) { + return null; + } + DefinitionDocument.Entry entry = document.entry(line); + PartType type = entry != null ? PartType.byKey(entry.key()) : null; + return type != null && type.appliesTo(kind) ? new WorkshopPart(type, line, entry.key(), entry.value()) : null; + } + + /** + * Adds a part where the parser expects it: a part with a parent right behind the given parent + * and its other children, anything else behind the last line of its own type, or at the end. + * + * @param parentLine the selected line, used to find the parent of a part that needs one + * @return the line of the part, an existing one for a type there can only be one of, or -1 + * when it needs a parent and there is none + */ + public int addPart(PartType type, int parentLine, String value) { + List parts = parts(); + if (type.isSingle()) { + for (WorkshopPart part : parts) { + if (part.type() == type) { + return part.line(); + } + } + } + int at; + if (type.parent() != null) { + at = afterParent(type, parts, parentLine); + if (at < 0) { + return -1; + } + } else { + at = document.size(); + for (WorkshopPart part : parts) { + if (part.type() == type) { + at = part.line() + 1; + } + } + } + document.insert(at, type.key(), value); + return at; + } + + private static int afterParent(PartType type, List parts, int selected) { + int parent = -1; + for (WorkshopPart part : parts) { + if (part.type() == type.parent() && part.line() <= selected) { + parent = part.line(); + } + } + if (parent < 0) { + // Nothing selected on or under a parent: the last parent there is. + for (WorkshopPart part : parts) { + if (part.type() == type.parent()) { + parent = part.line(); + } + } + } + if (parent < 0) { + return -1; + } + int at = parent + 1; + for (WorkshopPart part : parts) { + if (part.line() >= at && part.type() == type) { + at = part.line() + 1; + } else if (part.line() >= at && part.type() != type) { + break; + } + } + return at; + } + + /** Whether the document differs from what is on disk. */ + public boolean isDirty() { + return !document.snapshot().equals(saved); + } + + /** Writes the definition and the manifest to the pack folder. */ + public void save() throws WorkshopException { + try { + write(); + saved = document.snapshot(); + } catch (IOException e) { + throw new WorkshopException("Could not save: " + e); + } + } + + private void write() throws IOException { + Path file = assets().resolve(kind.folder()).resolve(vehicle + ".txt"); + Files.createDirectories(file.getParent()); + writeAtomically(file, document.text()); + writeAtomically(root.resolve(PackManifest.FILE), manifest.text()); + } + + /** A crash halfway through a save leaves the old file rather than half a new one. */ + private static void writeAtomically(Path file, String text) throws IOException { + Path temp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.writeString(temp, text, StandardCharsets.UTF_8); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Saves, checks the result the way the game will and zips it up for sharing. + * + * @param folder where the zip goes + * @return the zip file + */ + public Path export(Path folder) throws WorkshopException { + save(); + try (ContentPack pack = ContentPack.open(root)) { + Files.createDirectories(folder); + String name = FILE_SAFE.matcher(manifest.id() + "-" + manifest.version()).replaceAll("_").toLowerCase(Locale.ROOT) + ".zip"; + Path zip = folder.resolve(name); + Files.write(zip, pack.archive()); + return zip; + } catch (IOException e) { + throw new WorkshopException("Could not export: " + e); + } catch (PackException e) { + throw new WorkshopException("The pack would not load: " + e.getMessage()); + } + } + + /** A small plain PNG, so that a model without a texture of its own still draws. */ + static byte[] plainTexture() throws IOException { + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (DeflaterOutputStream deflate = new DeflaterOutputStream(raw, new Deflater())) { + // One row of one RGBA pixel, after its filter byte. + deflate.write(new byte[]{0, (byte) PLAIN_TEXTURE_GREY, (byte) PLAIN_TEXTURE_GREY, (byte) PLAIN_TEXTURE_GREY, (byte) 0xFF}); + } + ByteArrayOutputStream png = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(png)) { + out.write(new byte[]{(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}); + ByteArrayOutputStream header = new ByteArrayOutputStream(); + try (DataOutputStream h = new DataOutputStream(header)) { + h.writeInt(1); + h.writeInt(1); + h.write(new byte[]{8, 6, 0, 0, 0}); + } + chunk(out, "IHDR", header.toByteArray()); + chunk(out, "IDAT", raw.toByteArray()); + chunk(out, "IEND", new byte[0]); + } + return png.toByteArray(); + } + + private static void chunk(DataOutputStream out, String type, byte[] data) throws IOException { + byte[] name = type.getBytes(StandardCharsets.US_ASCII); + CRC32 crc = new CRC32(); + crc.update(name); + crc.update(data); + out.writeInt(data.length); + out.write(name); + out.write(data); + out.writeInt((int) crc.getValue()); + } +} diff --git a/common/src/main/resources/assets/mcheli/lang/en_us.json b/common/src/main/resources/assets/mcheli/lang/en_us.json index d3ce978b..2c0683fe 100644 --- a/common/src/main/resources/assets/mcheli/lang/en_us.json +++ b/common/src/main/resources/assets/mcheli/lang/en_us.json @@ -34,6 +34,162 @@ "entity.mcheli.tv_missile": "TV-Guided Missile", "entity.mcheli.uav_station": "UAV Station", "entity.mcheli.vehicle": "Ground Vehicle", + "gui.mcheli.drafting_table.workshop": "Workshop", + "gui.mcheli.drafting_table.workshop_hint": "Build your own vehicle from an OBJ model", + "gui.mcheli.workshop.title": "MCHeli Workshop", + "gui.mcheli.workshop.home.open": "Open a vehicle", + "gui.mcheli.workshop.home.new": "New vehicle", + "gui.mcheli.workshop.home.none": "No vehicles in mcheli/packs yet", + "gui.mcheli.workshop.home.drop": "Drop an OBJ file on this window, or paste its path. A PNG with the same name comes along as its texture.", + "gui.mcheli.workshop.new.vehicle": "Vehicle name (a-z, 0-9, - and _)", + "gui.mcheli.workshop.new.pack": "Pack id", + "gui.mcheli.workshop.new.model": "OBJ file", + "gui.mcheli.workshop.new.kind": "Kind: %s", + "gui.mcheli.workshop.new.create": "Create", + "gui.mcheli.workshop.new.name_taken": "The imported content already has a vehicle called %s. Pick another name.", + "gui.mcheli.workshop.new.bad_path": "That is not a file path: %s", + "gui.mcheli.workshop.kind.helicopter": "Helicopter", + "gui.mcheli.workshop.kind.plane": "Plane", + "gui.mcheli.workshop.kind.tank": "Tank or car", + "gui.mcheli.workshop.kind.vehicle": "Ground gun", + "gui.mcheli.workshop.tab.parts": "Parts", + "gui.mcheli.workshop.tab.tuning": "Tuning", + "gui.mcheli.workshop.tab.pack": "Pack", + "gui.mcheli.workshop.undo": "Undo", + "gui.mcheli.workshop.redo": "Redo", + "gui.mcheli.workshop.save": "Save", + "gui.mcheli.workshop.discard": "Don't save", + "gui.mcheli.workshop.export": "Export", + "gui.mcheli.workshop.test_flight": "Test flight", + "gui.mcheli.workshop.test_flight_owner_only": "Only in a single player or LAN world you host. A server has to have the pack first.", + "gui.mcheli.workshop.add": "Add this part", + "gui.mcheli.workshop.delete": "Delete", + "gui.mcheli.workshop.mirror": "Mirror", + "gui.mcheli.workshop.mirror_hint": "Adds a copy on the other side of the vehicle", + "gui.mcheli.workshop.on": "on", + "gui.mcheli.workshop.off": "off", + "gui.mcheli.workshop.needs_parent": "Add a %s first; this part attaches to one", + "gui.mcheli.workshop.select_hint": "Pick a part from the list or click its handle on the model. Pick a type at the top of the list and press + to add one.", + "gui.mcheli.workshop.hint": "Left drag turns, right drag moves, wheel zooms, R resets. Drag a handle or arrow to move a part; Shift turns off the snap.", + "gui.mcheli.workshop.saved": "Saved to %s", + "gui.mcheli.workshop.exported": "Exported to %s", + "gui.mcheli.workshop.unsaved": "Unsaved changes", + "gui.mcheli.workshop.unsaved_question": "Save this vehicle before closing?", + "gui.mcheli.workshop.pack.display_name": "Vehicle's display name", + "gui.mcheli.workshop.pack.name": "Pack name", + "gui.mcheli.workshop.pack.author": "Author", + "gui.mcheli.workshop.pack.version": "Version", + "gui.mcheli.workshop.pack.description": "Description", + "gui.mcheli.workshop.pack.id": "Pack id: %s", + "gui.mcheli.workshop.pack.vehicle": "Vehicle: %s", + "gui.mcheli.workshop.part.seat": "Seat", + "gui.mcheli.workshop.part.gunner_seat": "Gunner seat", + "gui.mcheli.workshop.part.fixed_seat": "Fixed view seat", + "gui.mcheli.workshop.part.camera": "Camera", + "gui.mcheli.workshop.part.camera_turret": "Camera turret", + "gui.mcheli.workshop.part.rotor": "Rotor", + "gui.mcheli.workshop.part.propeller": "Propeller hub", + "gui.mcheli.workshop.part.blade": "Propeller blades", + "gui.mcheli.workshop.part.wing": "Moving wing", + "gui.mcheli.workshop.part.pylon": "Wing pylon", + "gui.mcheli.workshop.part.flap": "Flap or lever", + "gui.mcheli.workshop.part.nozzle": "VTOL nozzle", + "gui.mcheli.workshop.part.wheel": "Wheel", + "gui.mcheli.workshop.part.steering_wheel": "Cockpit wheel", + "gui.mcheli.workshop.part.ground_wheels": "Ground contact", + "gui.mcheli.workshop.part.track_roller": "Track roller", + "gui.mcheli.workshop.part.crawler_track": "Track", + "gui.mcheli.workshop.part.landing_gear": "Landing gear", + "gui.mcheli.workshop.part.landing_gear_reversed": "Gear, reversed", + "gui.mcheli.workshop.part.landing_gear_hatch": "Gear door", + "gui.mcheli.workshop.part.landing_gear_sliding": "Gear, sliding", + "gui.mcheli.workshop.part.canopy": "Canopy", + "gui.mcheli.workshop.part.canopy_sliding": "Canopy, sliding", + "gui.mcheli.workshop.part.hatch": "Hatch", + "gui.mcheli.workshop.part.hatch_sliding": "Hatch, sliding", + "gui.mcheli.workshop.part.weapon": "Weapon", + "gui.mcheli.workshop.part.turret_weapon": "Turret weapon", + "gui.mcheli.workshop.part.turret": "Turret pivot", + "gui.mcheli.workshop.part.hit_box": "Hit box", + "gui.mcheli.workshop.part.engine": "Engine", + "gui.mcheli.workshop.part.fuel_tank": "Fuel tank", + "gui.mcheli.workshop.field.position": "Position", + "gui.mcheli.workshop.field.camera": "Camera", + "gui.mcheli.workshop.field.pivot": "Steering pivot", + "gui.mcheli.workshop.field.axis": "Axis", + "gui.mcheli.workshop.field.second_axis": "Second axis", + "gui.mcheli.workshop.field.slide": "Slide by", + "gui.mcheli.workshop.field.rotating_seat": "Turns with view", + "gui.mcheli.workshop.field.switch_gunner": "Leaves gunner view", + "gui.mcheli.workshop.field.min_pitch": "Lowest pitch", + "gui.mcheli.workshop.field.max_pitch": "Highest pitch", + "gui.mcheli.workshop.field.min_yaw": "Lowest yaw", + "gui.mcheli.workshop.field.max_yaw": "Highest yaw", + "gui.mcheli.workshop.field.yaw": "Yaw", + "gui.mcheli.workshop.field.pitch": "Pitch", + "gui.mcheli.workshop.field.default_yaw": "Resting yaw", + "gui.mcheli.workshop.field.always": "Always this view", + "gui.mcheli.workshop.field.yaw_sync": "Follows yaw", + "gui.mcheli.workshop.field.pitch_sync": "Follows pitch", + "gui.mcheli.workshop.field.blades": "Blades", + "gui.mcheli.workshop.field.blade_step": "Degrees apart", + "gui.mcheli.workshop.field.foldable": "Folds", + "gui.mcheli.workshop.field.max_angle": "Angle", + "gui.mcheli.workshop.field.second_angle": "Second angle", + "gui.mcheli.workshop.field.angle": "Angle", + "gui.mcheli.workshop.field.steer": "Steering angle", + "gui.mcheli.workshop.field.more_z": "More Z values", + "gui.mcheli.workshop.field.reverse": "Runs backwards", + "gui.mcheli.workshop.field.link_spacing": "Link spacing", + "gui.mcheli.workshop.field.x": "X", + "gui.mcheli.workshop.field.outline": "Outline, y/z points", + "gui.mcheli.workshop.field.weapon": "Weapon", + "gui.mcheli.workshop.field.pilot": "Pilot can fire", + "gui.mcheli.workshop.field.seat": "Fired from seat", + "gui.mcheli.workshop.field.width": "Width", + "gui.mcheli.workshop.field.height": "Height", + "gui.mcheli.workshop.field.damage_factor": "Damage factor", + "gui.mcheli.workshop.tuning.maxhp": "Hit points", + "gui.mcheli.workshop.tuning.speed": "Top speed", + "gui.mcheli.workshop.tuning.motionfactor": "Drag factor", + "gui.mcheli.workshop.tuning.gravity": "Gravity", + "gui.mcheli.workshop.tuning.gravityinwater": "Water gravity", + "gui.mcheli.workshop.tuning.throttleupdown": "Throttle rate", + "gui.mcheli.workshop.tuning.mobilityyaw": "Yaw rate", + "gui.mcheli.workshop.tuning.mobilitypitch": "Pitch rate", + "gui.mcheli.workshop.tuning.mobilityroll": "Roll rate", + "gui.mcheli.workshop.tuning.mobilityyawonground": "Ground yaw", + "gui.mcheli.workshop.tuning.rotorspeed": "Rotor speed", + "gui.mcheli.workshop.tuning.maxfuel": "Fuel capacity", + "gui.mcheli.workshop.tuning.fuelconsumption": "Fuel use", + "gui.mcheli.workshop.tuning.damagefactor": "Rider damage", + "gui.mcheli.workshop.tuning.armordamagefactor": "Armor factor", + "gui.mcheli.workshop.tuning.armormindamage": "Armor minimum", + "gui.mcheli.workshop.tuning.width": "Body width", + "gui.mcheli.workshop.tuning.height": "Body height", + "gui.mcheli.workshop.tuning.stepheight": "Step height", + "gui.mcheli.workshop.tuning.ongroundpitch": "Ground pitch", + "gui.mcheli.workshop.tuning.inventorysize": "Cargo slots", + "gui.mcheli.workshop.tuning.float": "Floats", + "gui.mcheli.workshop.tuning.enableback": "Can reverse", + "gui.mcheli.workshop.tuning.regeneration": "Self repair", + "gui.mcheli.workshop.tuning.enablegunnermode": "Gunner mode", + "gui.mcheli.workshop.tuning.enablenightvision": "Night vision", + "gui.mcheli.workshop.tuning.enableejectionseat": "Ejection seats", + "gui.mcheli.workshop.tuning.enablefoldblade": "Blades fold", + "gui.mcheli.workshop.tuning.enablevtol": "VTOL", + "gui.mcheli.workshop.tuning.variablesweepwing": "Swing wings", + "gui.mcheli.workshop.tuning.vtolyaw": "VTOL yaw", + "gui.mcheli.workshop.tuning.vtolpitch": "VTOL pitch", + "gui.mcheli.workshop.tuning.sweepwingspeed": "Swept speed", + "gui.mcheli.workshop.tuning.pivotturnthrottle": "Pivot turn", + "gui.mcheli.workshop.tuning.ongroundpitchfactor": "Terrain pitch", + "gui.mcheli.workshop.tuning.ongroundrollfactor": "Terrain roll", + "gui.mcheli.workshop.tuning.weightedcenterz": "Balance point", + "gui.mcheli.workshop.tuning.weighttype": "Weight", + "message.mcheli.workshop.test_flight_owner_only": "Test flights only work in a world you host", + "message.mcheli.workshop.test_flight_riding": "Get out of what you are riding first", + "message.mcheli.workshop.test_flight_no_vehicle": "%s did not load, so there is nothing to fly. Check the pack:", "gui.mcheli.drafting_table.create": "Create", "gui.mcheli.drafting_table.empty": "No recipes in this list", "gui.mcheli.drafting_table.fuel": "Fuel: %s", diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/DefinitionDocumentTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/DefinitionDocumentTest.java new file mode 100644 index 00000000..d711759e --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/DefinitionDocumentTest.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli.workshop; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DefinitionDocumentTest { + private static final String TEXT = """ + // A comment + DisplayName = Scout + + ; another comment + Speed = 0.5 + NotAKeyWeKnow = 1, 2, 3 + Empty = + AddSeat = 0, 1, 0 + AddSeat = 1, 1, 0 + """; + + @Test + void untouchedLinesSurviveARoundTrip() { + DefinitionDocument doc = new DefinitionDocument(TEXT); + doc.set("Speed", "0.75"); + assertEquals(TEXT.replace("Speed = 0.5", "Speed = 0.75"), doc.text()); + } + + @Test + void entriesFollowTheReadersRules() { + List entries = new DefinitionDocument(TEXT).entries(); + assertEquals(List.of("DisplayName", "Speed", "NotAKeyWeKnow", "AddSeat", "AddSeat"), + entries.stream().map(DefinitionDocument.Entry::key).toList()); + assertEquals("1, 2, 3", entries.get(2).value()); + } + + @Test + void theLastLineOfAKeyIsTheOneInEffect() { + DefinitionDocument doc = new DefinitionDocument("Speed = 1\nspeed = 2\n"); + assertEquals("2", doc.value("SPEED")); + doc.set("Speed", "3"); + assertEquals("Speed = 1\nspeed = 3\n", doc.text()); + } + + @Test + void settingNullRemovesEveryLineOfAKey() { + DefinitionDocument doc = new DefinitionDocument("Speed = 1\nMaxHp = 5\nspeed = 2\n"); + doc.set("Speed", null); + assertEquals("MaxHp = 5\n", doc.text()); + assertNull(doc.value("Speed")); + } + + @Test + void aMissingKeyGoesAtTheEnd() { + DefinitionDocument doc = new DefinitionDocument("MaxHp = 5\n"); + doc.set("Speed", "1"); + assertEquals("MaxHp = 5\nSpeed = 1\n", doc.text()); + } + + @Test + void aByteOrderMarkIsDropped() { + assertEquals("Speed = 1\n", new DefinitionDocument("Speed = 1\n").text()); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/EditHistoryTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/EditHistoryTest.java new file mode 100644 index 00000000..a0bd912e --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/EditHistoryTest.java @@ -0,0 +1,51 @@ +package com.jnhprojects.mcheli.workshop; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EditHistoryTest { + @Test + void undoAndRedoWalkTheSteps() { + EditHistory history = new EditHistory<>(); + history.record("a", null); + history.record("b", null); + assertEquals("b", history.undo("c")); + assertEquals("a", history.undo("b")); + assertFalse(history.canUndo()); + assertEquals("b", history.redo("a")); + assertEquals("c", history.redo("b")); + assertFalse(history.canRedo()); + } + + @Test + void changesWithTheSameKeyMerge() { + EditHistory history = new EditHistory<>(); + history.record("a", "drag"); + history.record("ab", "drag"); + history.record("abc", "drag"); + assertEquals("a", history.undo("abcd")); + assertFalse(history.canUndo()); + } + + @Test + void endingAMergeStartsANewStep() { + EditHistory history = new EditHistory<>(); + history.record("a", "drag"); + history.endMerge(); + history.record("b", "drag"); + assertEquals("b", history.undo("c")); + assertTrue(history.canUndo()); + } + + @Test + void aNewChangeClearsRedo() { + EditHistory history = new EditHistory<>(); + history.record("a", null); + history.undo("b"); + history.record("a", null); + assertFalse(history.canRedo()); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/ModelImportTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/ModelImportTest.java new file mode 100644 index 00000000..7412d86a --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/ModelImportTest.java @@ -0,0 +1,161 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.DefinitionReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModelImportTest { + private static final String CUBE = """ + o $body + v 0 0 0 + v 2 0 0 + v 2 3 0 + v 0 3 0 + vt 0 0 + vt 1 0 + vt 1 1 + vt 0 1 + f 1/1 2/2 3/3 4/4 + """; + + @TempDir + Path dir; + + private Path write(String name, String text) throws IOException { + Path file = dir.resolve(name); + Files.writeString(file, text); + return file; + } + + private String errorOf(Path file) { + return assertThrows(WorkshopException.class, () -> ModelImport.check(file)).getMessage(); + } + + @Test + void aGoodModelHasNoWarnings() throws Exception { + ModelImport model = ModelImport.check(write("cube.obj", CUBE)); + assertEquals(List.of(), model.warnings()); + assertEquals(2, model.mesh().triangleCount()); + assertEquals(2.0F, model.max()[0]); + assertEquals(3.0F, model.max()[1]); + } + + @Test + void largerPolygonsBecomeTriangles() throws Exception { + String pentagon = """ + o $body + v 0 0 0 + v 1 0 0 + v 2 1 0 + v 1 2 0 + v 0 1 0 + f 1 2 3 4 5 + """; + assertEquals(3, ModelImport.check(write("five.obj", pentagon)).mesh().triangleCount()); + } + + @Test + void otherFormatsAreNamedAsSuch() throws Exception { + assertTrue(errorOf(write("heli.fbx", "x")).contains("not an OBJ")); + } + + @Test + void aMissingFileIsReported() { + assertTrue(errorOf(dir.resolve("gone.obj")).contains("No file")); + } + + @Test + void aBinaryFileIsNotAnObj() throws Exception { + Path file = dir.resolve("fake.obj"); + Files.write(file, new byte[]{'g', 'l', 'T', 'F', 0, 0, 0, 2}); + assertTrue(errorOf(file).contains("not a text file")); + } + + @Test + void aFaceThatUsesAMissingVertexNamesTheLine() throws Exception { + String message = errorOf(write("bad.obj", "v 0 0 0\nv 1 0 0\nf 1 2 9\n")); + assertTrue(message.contains("line 3"), message); + assertTrue(message.contains("vertex 9"), message); + } + + @Test + void aBadNumberNamesTheLine() throws Exception { + String message = errorOf(write("bad.obj", "v 0 0 0\nv one 0 0\n")); + assertTrue(message.contains("line 2"), message); + } + + @Test + void aFileWithNoFacesIsRefused() throws Exception { + assertTrue(errorOf(write("empty.obj", "v 0 0 0\n")).contains("no faces")); + } + + @Test + void aModelWithoutABodyIsWarnedAbout() throws Exception { + ModelImport model = ModelImport.check(write("nobody.obj", CUBE.replace("o $body\n", ""))); + assertTrue(model.warnings().stream().anyMatch(w -> w.contains("$body"))); + } + + @Test + void aSixteenTimesTooLargeModelIsWarnedAbout() throws Exception { + ModelImport model = ModelImport.check(write("big.obj", CUBE.replace("v 2 3 0", "v 2 300 0"))); + assertTrue(model.warnings().stream().anyMatch(w -> w.contains("1/16"))); + } + + @Test + void anUnmappedModelIsWarnedAbout() throws Exception { + ModelImport model = ModelImport.check(write("plain.obj", "o $body\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n")); + assertTrue(model.warnings().stream().anyMatch(w -> w.contains("texture coordinates"))); + } + + @Test + void theTextureIsFoundByTheMaterialFile() throws Exception { + Path obj = write("heli.obj", CUBE); + write("heli.mtl", "newmtl paint\nmap_Kd paint_job.png\n"); + Path png = write("paint_job.png", "not checked here"); + assertEquals(png.toAbsolutePath(), ModelImport.findTexture(obj)); + } + + @Test + void theTextureIsFoundByName() throws Exception { + Path obj = write("heli.obj", CUBE); + Path png = write("heli.png", "not checked here"); + assertEquals(png.toAbsolutePath(), ModelImport.findTexture(obj)); + } + + @Test + void noTextureIsFine() throws Exception { + assertNull(ModelImport.findTexture(write("heli.obj", CUBE))); + } + + @Test + void partsTheModelLacksAreListed() throws Exception { + ModelImport model = ModelImport.check(write("heli.obj", CUBE + "o $blade0\nf 1 2 3\n")); + HelicopterDefinition def = DefinitionReader.read(""" + AddSeat = 0, 1, 0 + AddRotor = 2, 180, 0, 2, 0, 0, 1, 0 + AddRotor = 2, 180, 0, 2, -3, 1, 0, 0 + AddPartLG = 0, 0, 0, 1, 0, 0 + """, new HelicopterDefinition("heli")).definition(); + assertEquals(List.of("$blade1", "$lg0"), ModelImport.missingParts(def, model.mesh())); + } + + @Test + void eitherCanopyNameCounts() throws Exception { + ModelImport model = ModelImport.check(write("heli.obj", CUBE + "o $canopy\nf 1 2 3\n")); + HelicopterDefinition def = DefinitionReader.read("AddSeat = 0, 1, 0\nAddPartCanopy = 0, 0, 0, 1, 0, 0\n", + new HelicopterDefinition("heli")).definition(); + assertFalse(ModelImport.missingParts(def, model.mesh()).contains("$canopy0")); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/PartTypeTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/PartTypeTest.java new file mode 100644 index 00000000..f52918e3 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/PartTypeTest.java @@ -0,0 +1,168 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.DefinitionReader; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.io.IOException; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The part table repeats what the parsers in {@code AircraftDefinition} read, so it is checked against them. */ +class PartTypeTest { + private static WorkshopKind kindFor(PartType type) { + for (WorkshopKind kind : WorkshopKind.values()) { + if (type.appliesTo(kind)) { + return kind; + } + } + throw new AssertionError(type + " applies to nothing"); + } + + private static AircraftDefinition read(WorkshopKind kind, String lines) throws IOException { + DefinitionReader.Result result = DefinitionReader.read("AddSeat = 0, 1, 0\n" + lines, kind.create("test")); + assertEquals(java.util.List.of(), result.problems()); + assertEquals(Set.of(), result.unhandledKeys()); + assertTrue(result.isValid()); + return result.definition(); + } + + @ParameterizedTest + @EnumSource(PartType.class) + void everyKeyIsKnownToItsParser(PartType type) throws IOException { + StringBuilder lines = new StringBuilder(); + if (type.parent() != null) { + lines.append(type.parent().key()).append(" = ").append(type.parent().defaultValue()).append('\n'); + } + lines.append(type.key()).append(" = ").append(type.defaultValue()).append('\n'); + read(kindFor(type), lines.toString()); + } + + @ParameterizedTest + @EnumSource(PartType.class) + void everyKeyFindsItsType(PartType type) { + assertSame(type, PartType.byKey(type.key().toUpperCase(java.util.Locale.ROOT))); + } + + @ParameterizedTest + @EnumSource(PartType.class) + void fieldsDoNotOverlap(PartType type) { + int next = 0; + for (PartField field : type.fields()) { + assertEquals(next, field.index(), type + " " + field.id()); + next = field.index() + field.kind().width(); + } + } + + @Test + void aNewSeatLoads() throws IOException { + AircraftDefinition def = read(WorkshopKind.PLANE, PartType.GUNNER_SEAT.key() + " = " + PartType.GUNNER_SEAT.defaultValue()); + assertEquals(2, def.seats.size()); + assertTrue(def.seats.get(1).gunner()); + } + + @Test + void newRotorsKeepTheirEightValues() throws IOException { + AircraftDefinition def = read(WorkshopKind.HELICOPTER, "AddRotor = " + PartType.ROTOR.defaultValue()); + assertEquals(1, ((HelicopterDefinition) def).rotors.size()); + } + + @Test + void childPartsAttachToTheirParent() throws IOException { + PlaneDefinition def = (PlaneDefinition) read(WorkshopKind.PLANE, + "AddPartWing = " + PartType.WING.defaultValue() + "\nAddPartPylon = " + PartType.PYLON.defaultValue()); + assertEquals(1, def.wings.getFirst().pylons().size()); + } + + @Test + void movingAPositionOnlyTouchesItsValues() { + WorkshopPart part = new WorkshopPart(PartType.WEAPON, 0, "AddWeapon", "m230, 1, 2, 3, 45, 5"); + String changed = part.with(PartType.WEAPON.mainPosition(), new Vector3(4, 5.5, -6.25)); + assertEquals("m230, 4, 5.5, -6.25, 45, 5", changed); + } + + @Test + void settingALaterFieldFillsTheOnesBefore() throws IOException { + WorkshopPart part = new WorkshopPart(PartType.GUNNER_SEAT, 0, "AddGunnerSeat", "0, 1, 0"); + PartField maxPitch = PartType.GUNNER_SEAT.fields().get(4); + assertEquals("max_pitch", maxPitch.id()); + String changed = part.with(maxPitch, 0, "50"); + assertEquals("0, 1, 0, 0, 0, 0, false, -30, 50", changed); + + AircraftDefinition def = read(WorkshopKind.TANK, "AddGunnerSeat = " + changed); + assertEquals(50.0F, def.seats.get(1).maxPitch()); + assertEquals(-30.0F, def.seats.get(1).minPitch()); + } + + @Test + void anOptionalFieldLeftAtItsFallbackIsNotWritten() { + WorkshopPart part = new WorkshopPart(PartType.SEAT, 0, "AddSeat", "0, 1, 0"); + PartField rotating = PartType.SEAT.fields().get(1); + assertEquals("0, 1, 0", part.with(rotating, 0, "false")); + assertEquals("0, 1, 0, true", part.with(rotating, 0, "true")); + } + + @Test + void aLineIsNeverShortenedBelowWhatItHad() { + // Five values make a camera fixed even at yaw zero, which rewriting must not undo. + WorkshopPart part = new WorkshopPart(PartType.CAMERA, 0, "CameraPosition", "0, 1, 0, false, 0"); + assertEquals("1, 1, 0, false, 0", part.with(PartType.CAMERA.mainPosition(), new Vector3(1, 1, 0))); + } + + @Test + void theRestOfALineStaysTogether() { + WorkshopPart part = new WorkshopPart(PartType.GROUND_WHEELS, 0, "SetWheelPos", "1.5, -0.24, 2, 0, -2"); + PartField rest = PartType.GROUND_WHEELS.fields().get(1); + assertEquals("0, -2", part.get(rest, 0)); + assertEquals("1.5, -0.24, 2, -3", part.with(rest, 0, "-3")); + } + + @Test + void numbersAreWrittenShort() { + assertEquals("1.5", WorkshopPart.number(1.5)); + assertEquals("2", WorkshopPart.number(2.0)); + assertEquals("0", WorkshopPart.number(-0.00001)); + assertEquals("0.0625", WorkshopPart.number(0.0625)); + assertEquals("100", WorkshopPart.number(100.0)); + } + + @Test + void tuningDefaultsComeFromABlankDefinition() { + TuningKey speed = TuningKey.ALL.stream().filter(k -> k.key().equals("Speed")).findFirst().orElseThrow(); + assertEquals("0.1", speed.defaultFor(WorkshopKind.PLANE)); + assertTrue(speed.accepts("1.8")); + assertFalse(speed.accepts("9")); + assertFalse(speed.accepts("fast")); + } + + @Test + void everyTuningKeyIsKnownToItsParser() throws IOException { + for (WorkshopKind kind : WorkshopKind.values()) { + AircraftDefinition blank = kind.create("test"); + for (TuningKey key : TuningKey.ALL) { + if (key.applies().test(blank)) { + read(kind, key.key() + " = " + key.defaultFor(kind)); + } + } + } + } + + @Test + void tuningOnlyShowsKeysOfTheKind() { + TuningKey weight = TuningKey.ALL.stream().filter(k -> k.key().equals("WeightType")).findFirst().orElseThrow(); + assertTrue(weight.applies().test(new TankDefinition("test"))); + assertFalse(weight.applies().test(new PlaneDefinition("test"))); + assertNotNull(weight.defaultFor(WorkshopKind.TANK)); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopCameraTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopCameraTest.java new file mode 100644 index 00000000..50097cd9 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopCameraTest.java @@ -0,0 +1,96 @@ +package com.jnhprojects.mcheli.workshop; + +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkshopCameraTest { + private static final float[] BOX_MIN = {-1, 0, -2}; + private static final float[] BOX_MAX = {1, 2, 2}; + + @Test + void framingPutsTheMiddleOfTheBoxInTheMiddleOfTheView() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + Vector3f middle = camera.project(0, 1, 0); + assertEquals(0.0F, middle.x, 1.0E-3F); + assertEquals(0.0F, middle.y, 1.0E-3F); + } + + @Test + void framingFitsTheBox() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + for (float x : new float[]{-1, 1}) { + for (float y : new float[]{0, 2}) { + for (float z : new float[]{-2, 2}) { + Vector3f corner = camera.project(x, y, z); + assertTrue(Math.abs(corner.x) <= 100.0F && Math.abs(corner.y) <= 50.0F, corner.toString()); + } + } + } + } + + @Test + void upOnTheVehicleIsUpOnTheScreen() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + // GUI Y grows downwards. + assertTrue(camera.project(0, 2, 0).y < camera.project(0, 0, 0).y); + } + + @Test + void theStartingViewShowsTheNose() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + // Z grows away from the viewer, and the nose points along +Z. + assertTrue(camera.project(0, 0, 2).z < camera.project(0, 0, -2).z); + } + + @Test + void theProjectionAgreesWithRotationAndTranslation() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + camera.orbit(50, 20); + camera.pan(10, -4); + Vector3f projected = camera.project(0.5, 1.5, -1); + Vector3f drawn = camera.rotation().transform(new Vector3f(0.5F, 1.5F, -1)).add(camera.translation()).mul(camera.scale()); + assertEquals(projected.x, drawn.x, 1.0E-3F); + assertEquals(projected.y, drawn.y, 1.0E-3F); + } + + @Test + void unprojectUndoesTheProjection() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + camera.orbit(33, -12); + Vector3f before = camera.project(1, 1, 1); + Vector3f move = camera.unproject(15, -7); + Vector3f after = camera.project(1 + move.x, 1 + move.y, 1 + move.z); + assertEquals(before.x + 15, after.x, 1.0E-2F); + assertEquals(before.y - 7, after.y, 1.0E-2F); + assertEquals(before.z, after.z, 1.0E-3F); + } + + @Test + void panningMovesWhatIsUnderTheMouseWithIt() { + WorkshopCamera camera = new WorkshopCamera(); + camera.frame(BOX_MIN, BOX_MAX, 200, 100); + Vector3f before = camera.project(0, 0, 0); + camera.pan(20, 10); + Vector3f after = camera.project(0, 0, 0); + assertEquals(before.x + 20, after.x, 1.0E-2F); + assertEquals(before.y + 10, after.y, 1.0E-2F); + } + + @Test + void lookingUpAndDownStopsAtThePoles() { + WorkshopCamera camera = new WorkshopCamera(); + camera.orbit(0, 100000); + Vector3f up = camera.project(0, 1, 0); + // Nearly straight down onto the vehicle, so its height barely shows on the screen. + assertTrue(Math.abs(up.y) < camera.scale() * 0.05F); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopProjectTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopProjectTest.java new file mode 100644 index 00000000..b2d63a49 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopProjectTest.java @@ -0,0 +1,264 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackFolder; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** What the workshop writes has to be a pack the game loads like any other. */ +class WorkshopProjectTest { + private static final Path EXAMPLE = Path.of(System.getProperty("mcheli.examplePack", "../examples/rework-example-pack")); + + @TempDir + Path dir; + private Path packs; + private Path obj; + + @BeforeEach + void setUp() throws Exception { + packs = Files.createDirectory(dir.resolve("packs")); + obj = dir.resolve("My Model.obj"); + Files.writeString(obj, """ + o $body + v 0 0 0 + v 1 0 0 + v 1 1 0 + vt 0 0 + vt 1 0 + vt 1 1 + f 1/1 2/2 3/3 + """); + } + + private WorkshopProject create() throws WorkshopException { + return WorkshopProject.create(packs, "my-pack", WorkshopKind.HELICOPTER, "my-heli", "Me", obj, null); + } + + private ContentRegistry load(Path pack) throws Exception { + ContentPack open = ContentPack.open(pack); + return ContentRegistry.load(List.of(open.contentSource())); + } + + @Test + void aNewProjectIsAPackThatLoads() throws Exception { + WorkshopProject project = create(); + assertEquals(List.of(), project.problems()); + + ContentRegistry registry = load(project.root()); + assertEquals(List.of(), registry.problems()); + HelicopterDefinition heli = registry.helicopter("my-heli"); + assertNotNull(heli); + assertEquals(1, heli.seatCount()); + assertEquals("my-pack", heli.pack()); + assertTrue(Files.isRegularFile(project.assets().resolve("textures/helicopters/my-heli.png"))); + } + + @Test + void thePlainTextureIsARealPng() throws Exception { + byte[] png = WorkshopProject.plainTexture(); + // Checked with the JDK's own reader, the game's is not available here. + java.awt.image.BufferedImage image = javax.imageio.ImageIO.read(new ByteArrayInputStream(png)); + assertEquals(1, image.getWidth()); + assertEquals(0xFFB0B0B0, image.getRGB(0, 0)); + } + + @Test + void aTextureIsTakenInUnderTheVehiclesName() throws Exception { + Path png = dir.resolve("Paint.png"); + Files.write(png, WorkshopProject.plainTexture()); + WorkshopProject project = WorkshopProject.create(packs, "painted", WorkshopKind.TANK, "my-tank", "", obj, png); + assertTrue(Files.isRegularFile(project.assets().resolve("textures/tanks/my-tank.png"))); + } + + @Test + void badNamesAndTakenIdsAreRefused() throws Exception { + assertThrows(WorkshopException.class, + () -> WorkshopProject.create(packs, "Upper Case", WorkshopKind.PLANE, "name", "", obj, null)); + assertThrows(WorkshopException.class, + () -> WorkshopProject.create(packs, "original", WorkshopKind.PLANE, "name", "", obj, null)); + assertThrows(WorkshopException.class, + () -> WorkshopProject.create(packs, "fine", WorkshopKind.PLANE, "Bad Name", "", obj, null)); + create(); + assertThrows(WorkshopException.class, this::create); + } + + @Test + void aBadModelLeavesNoPackBehind() { + assertThrows(WorkshopException.class, + () -> WorkshopProject.create(packs, "broken", WorkshopKind.PLANE, "name", "", dir.resolve("gone.obj"), null)); + assertFalse(Files.exists(packs.resolve("broken"))); + } + + @Test + void savingKeepsEditsAndLinesTheEditorHasNoPanelFor() throws Exception { + WorkshopProject project = create(); + project.document().insert(0, "SomeFutureKey", "1"); + project.document().set("Speed", "0.4"); + assertTrue(project.isDirty()); + project.save(); + assertFalse(project.isDirty()); + + WorkshopProject again = WorkshopProject.open(packs, WorkshopProject.list(packs).getFirst()); + assertEquals("1", again.document().value("SomeFutureKey")); + assertEquals("0.4", again.document().value("Speed")); + assertEquals(0.4F, again.definition().speed); + } + + @Test + void addingPartsPutsThemWhereTheParserExpectsThem() throws Exception { + WorkshopProject project = create(); + int second = project.addPart(PartType.SEAT, -1, PartType.SEAT.defaultValue()); + WorkshopPart seat = project.partAt(second); + assertNotNull(seat); + assertEquals(PartType.SEAT, seat.type()); + project.refresh(); + assertEquals(2, project.definition().seatCount()); + } + + @Test + void aSingleLinePartIsNotAddedTwice() throws Exception { + WorkshopProject project = create(); + int first = project.addPart(PartType.ENGINE, -1, "0, 1, -2"); + assertEquals(first, project.addPart(PartType.ENGINE, -1, "9, 9, 9")); + project.refresh(); + assertEquals(new Vector3(0, 1, -2), project.definition().enginePosition); + } + + @Test + void aChildNeedsItsParentAndGoesBehindIt() throws Exception { + WorkshopProject project = WorkshopProject.create(packs, "plane-pack", WorkshopKind.PLANE, "my-plane", "", obj, null); + assertEquals(-1, project.addPart(PartType.PYLON, -1, PartType.PYLON.defaultValue())); + + int wing0 = project.addPart(PartType.WING, -1, PartType.WING.defaultValue()); + int wing1 = project.addPart(PartType.WING, -1, PartType.WING.defaultValue()); + int pylon = project.addPart(PartType.PYLON, wing0, PartType.PYLON.defaultValue()); + assertEquals(wing0 + 1, pylon); + assertEquals(wing1 + 1, project.parts().stream().filter(p -> p.type() == PartType.WING).toList().get(1).line()); + + project.refresh(); + com.jnhprojects.mcheli.content.aircraft.PlaneDefinition def = (com.jnhprojects.mcheli.content.aircraft.PlaneDefinition) project.definition(); + assertEquals(1, def.wings.get(0).pylons().size()); + assertEquals(0, def.wings.get(1).pylons().size()); + } + + @Test + void partsFromAnotherKindAreLeftAlone() throws Exception { + WorkshopProject project = create(); + project.document().insert(0, "AddPartWing", "0, 0, 0, 0, 1, 0, 90"); + assertTrue(project.parts().stream().noneMatch(p -> p.type() == PartType.WING)); + } + + @Test + void theModelsMissingPartsShowAsProblems() throws Exception { + WorkshopProject project = create(); + project.addPart(PartType.ROTOR, -1, PartType.ROTOR.defaultValue()); + project.refresh(); + assertTrue(project.problems().stream().anyMatch(p -> p.contains("$blade0"))); + } + + @Test + void aDefinitionTheGameWouldRejectKeepsTheLastGoodOne() throws Exception { + WorkshopProject project = create(); + int seat = project.parts().getFirst().line(); + project.document().removeLine(seat); + project.refresh(); + assertTrue(project.problems().stream().anyMatch(p -> p.contains("no seats"))); + assertEquals(1, project.definition().seatCount()); + } + + @Test + void theManifestSurvivesARoundTrip() throws Exception { + WorkshopProject project = create(); + project.setManifest(new PackManifest("my-pack", "My \\ pack", "Me = you", "2.0", "Line one\nline two")); + project.save(); + try (ContentPack pack = ContentPack.open(project.root())) { + assertEquals("My \\ pack", pack.manifest().name()); + assertEquals("Me = you", pack.manifest().author()); + assertEquals("2.0", pack.manifest().version()); + assertEquals("Line one line two", pack.manifest().description()); + } + } + + @Test + void exportingMakesAZipThatLoads() throws Exception { + WorkshopProject project = create(); + Path zip = project.export(dir.resolve("export")); + assertEquals("my-pack-1.0.0.zip", zip.getFileName().toString()); + try (ContentPack pack = ContentPack.open(zip)) { + assertEquals("my-pack", pack.id()); + ContentRegistry registry = ContentRegistry.load(List.of(pack.contentSource())); + assertNotNull(registry.helicopter("my-heli")); + } + } + + @Test + void aZipPackOpensFromACopyAndIsLeftAlone() throws Exception { + WorkshopProject first = create(); + Path zip = first.export(packs.resolveSibling("zips")); + Path installed = packs.resolve("my-pack.zip"); + deleteFolder(first.root()); + Files.move(zip, installed); + byte[] before = Files.readAllBytes(installed); + + List entries = WorkshopProject.list(packs); + assertEquals(1, entries.size()); + assertTrue(entries.getFirst().zipped()); + + WorkshopProject copy = WorkshopProject.open(packs, entries.getFirst()); + assertEquals("my-pack-edit", copy.manifest().id()); + assertEquals(packs.resolve("my-pack-edit"), copy.root()); + copy.document().set("Speed", "0.2"); + copy.save(); + assertEquals(List.of(), PackFolder.scan(packs).problems()); + assertTrue(java.util.Arrays.equals(before, Files.readAllBytes(installed))); + + // Opening it again goes back to the copy instead of starting over. + WorkshopProject again = WorkshopProject.open(packs, entries.getFirst()); + assertEquals("0.2", again.document().value("Speed")); + } + + @Test + void theExamplePackCanBeOpened() throws Exception { + Path copy = packs.resolve("rework-example"); + try (var walk = Files.walk(EXAMPLE)) { + for (Path from : walk.toList()) { + Path to = copy.resolve(EXAMPLE.relativize(from).toString()); + if (Files.isDirectory(from)) { + Files.createDirectories(to); + } else { + Files.copy(from, to); + } + } + } + WorkshopProject.Entry entry = WorkshopProject.list(packs).getFirst(); + assertEquals("rework-scout", entry.vehicle()); + WorkshopProject project = WorkshopProject.open(packs, entry); + assertEquals(List.of(), project.problems()); + assertEquals(5, project.parts().size()); + assertFalse(project.isDirty()); + } + + private static void deleteFolder(Path folder) throws Exception { + try (var walk = Files.walk(folder)) { + for (Path path : walk.sorted(java.util.Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0a7ca115..530b8ffd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,6 +37,7 @@ This document explains how MCHeli Rework is organized and why. It reflects the c │ model Mesh, MqoLoader, ObjLoader │ pure Java │ hud HudScript, Expression, HudVariable │ │ physics AircraftRotation, GroundImpact │ + │ workshop WorkshopProject, DefinitionDocument, PartType │ │ effect NightGlow │ │ config ConfigSpec (the TOML config format) │ └────────────────────────────────────────────────────────────────┘ @@ -687,6 +688,52 @@ layer shows a plain text readout instead. **MeshCache** parses models on first use and is cleared on resource reload (F3 + T). +## Workshop + +The workshop edits a content pack, so nothing else in the mod has to know it exists: a saved vehicle +loads, syncs and renders like any other. + +The pure half is `workshop` in `common`, tested without Minecraft: + +- **`DefinitionDocument`** is the definition as lines of text, with the `DefinitionReader` rules for + what counts as an entry. An edit changes one line, so comments and keys the editor has no panel + for survive. `DefinitionReader.read(String, T)` parses it on every edit (a file takes microseconds) + and that parsed definition is both what the screen draws and how problems are found, so the editor + and the game cannot disagree about what a line means. +- **`PartType`** is a table of the lines the editor places: the layout of each one's values + (`PartField`), and the value counts its parser accepts. Those counts matter, since several parsers + read meaning into a line's length: `WorkshopPart.with` writes the shortest accepted length that + keeps every value set, never less than the line had. `PartTypeTest` feeds every entry through the + real parser, so the table cannot drift from it. `TuningKey` does the same for the tuning page, + taking each default from a blank definition. +- **`WorkshopProject`** is one vehicle in one pack folder: create, open, save, export. Saving writes a + folder in `mcheli/packs` because a zip pack stays open while in use, which would make replacing one + fail on Windows. Opening a zip makes a folder copy with `-edit` in its id, which loads after the + original and so replaces its vehicles (see [load order](content/packs.md#load-order-and-replacing-vehicles)). +- **`ModelImport`** checks an OBJ and turns what is wrong into messages for its author. +- **`EditHistory`** is undo and redo by snapshot of the lines. Consecutive changes with one merge key + are one step, which is what makes a drag or a typed number undo in one go. +- **`WorkshopCamera`** is the orthographic view: `project` for gizmos, `unproject` for dragging. + +`client.workshop` holds the screens. `WorkshopScreen` hands an `AircraftRenderState` holding the +parsed definition to vanilla's `GuiGraphicsExtractor.entity`, which draws through the registered +`AircraftRenderer` into a picture-in-picture texture. So the preview is the real renderer with glass +and moving parts, on every loader, with no renderer of our own to register. The only difference is +`inGui`: glass is normally culled on the CPU by assuming the camera sits at the origin, which does +not hold in a GUI. `RuntimeAssets.setWorkshopRoot` puts the open pack ahead of the runtime roots, so +its model and texture draw before the game has loaded the pack. + +Gizmos are 2D GUI drawing on the points `WorkshopCamera.project` gives, in the stratum above the +picture. So they never hide behind the hull, which is where seats and pivots usually are. The camera's +rotation and translation go to `entity` as they are, and a test checks `project` against them. + +A test flight (`TestFlightPacket`, `workshop.TestFlight`) saves, reloads the content on the server as +`/mcheli reload` would, and seats the player in a vehicle made with `AircraftEntity.startTestFlight`. +It takes no damage, is not saved with the world, returns its pilot through the same path as a remote +pilot and discards itself on its next tick. Only the owner of an integrated server may ask: the pack +is on that machine, and a dedicated server has no way to get it yet. `WorkshopClient` keeps the screen +across the flight and reopens it when the player is out, or after five seconds if no vehicle came. + ## Assets The vehicle content is EMB4's and is All Rights Reserved, so the jar carries none of it. Players diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c1f5ff22..6648ff2e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -137,7 +137,7 @@ Rules that hold for every beta: | 8 | `1.1.0-beta.4` | Fuel, first equipment, hull hit boxes (Forge 26.3 only) | Released | | 9 | `1.1.0-beta.5` | Vehicle screen, cargo, supply vehicles, thermal view (Forge 26.3 only) | Released | | 10 | `1.1.0-beta.6` | The rest of the equipment (Forge 26.3 only) | Released | -| 11 | `1.1.0-beta.7` | Workshop: model, parts and tuning (Forge 26.3 only) | Planned | +| 11 | `1.1.0-beta.7` | Workshop: model, parts and tuning (Forge 26.3 only) | Released | | 12 | `1.1.0-beta.8` | Workshop: textures, sounds and sharing (Forge 26.3 only) | Planned | | 13 | `1.1.0-beta.9` | Every loader on every supported version, 1.19.4 to 26.3 | Planned | | | `1.1.0` | Release on CurseForge | Planned | @@ -279,13 +279,13 @@ Built and tested on Forge 26.3 only. What was not done in this phase moved to ph The first half of the [editor](#the-editor). Its output is a pack from phase 6, so anything built here syncs to other players with no extra work. -- [ ] Workshop block (or the drafting table) opening a 3D editor screen around the model -- [ ] OBJ import from Blockbench or Blender, with clear errors for unsupported files -- [ ] Gizmos for seats, cameras, rotors, wings, flaps, nozzles, wheels, tracks, landing gear, +- [x] The drafting table opening a 3D editor screen around the model +- [x] OBJ import from Blockbench or Blender, with clear errors for unsupported files +- [x] Gizmos for seats, cameras, rotors, wings, flaps, nozzles, wheels, tracks, landing gear, canopy and hatches, weapon mounts, hit boxes, engine and fuel tank -- [ ] Flight and drive tuning with a test flight from the editor -- [ ] Save, export and load packages; undo and redo -- [ ] Workshop guide in `docs/content/workshop.md` +- [x] Flight and drive tuning with a test flight from the editor +- [x] Save, export and load packages; undo and redo +- [x] Workshop guide in `docs/content/workshop.md` ## Phase 12: workshop, textures, sounds and sharing (`1.1.0-beta.8`) @@ -327,7 +327,6 @@ No new features after beta.9; a `1.1.0-rc.N` follows only if beta.9 testing find ## Open decisions -- **Workshop entry point.** A new workshop block, or a tab in the drafting table. - **Pack hosting.** Whether there is ever a shared place to publish packs, or only hand sharing and server sync. @@ -365,7 +364,8 @@ player's `mcheli/packs`. ### The editor -A workshop block (or the drafting table) opens a 3D editor screen around the model: +The drafting table opens a 3D editor screen around the model. A button there needs no new block, art +or recipe, and the editor is a screen of its own once it is open: - **Model:** import an OBJ made in Blockbench or Blender. Modelling a full airliner inside Minecraft is not realistic; a simple box builder for small vehicles can come later. diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md index 9934ed58..c4e47a7f 100644 --- a/docs/SCREENSHOTS.md +++ b/docs/SCREENSHOTS.md @@ -3,6 +3,20 @@ Every phase of MCHeli Rework in development builds, newest first. The [README](../README.md) shows the latest phase only. +## Phase 11 (`1.1.0-beta.7`) + +The vehicle workshop: importing a model, placing its parts, tuning, test flights and saving a pack. +The helicopter is the example pack's Rework Scout. + +| | | +|---|---| +| ![The workshop editor: a small helicopter on a grid, a hit box outlined around its cockpit, coloured handles on its parts, and the main rotor selected with arrows, its axis and its values in the side panel](images/phase-11/workshop-editor.png) | ![The tuning page beside the helicopter, listing hit points, top speed, gravity, turn rates, fuel and armor](images/phase-11/workshop-tuning.png) | +| Place every part by dragging it on the model | Tune how it flies; values you changed stand out from the defaults | +| ![The workshop's first screen: vehicles in the packs on the left, and a form for a new vehicle with a dropped OBJ file on the right](images/phase-11/workshop-import.png) | ![The view from the helicopter's cockpit in flight over a desert coast, with the instruments on the HUD](images/phase-11/workshop-test-flight.png) | +| Drop an OBJ on the window to start a vehicle | Test flight straight from the editor | +| ![The pack page with the vehicle's name, author, version and description, and a message that the pack was exported to a zip](images/phase-11/workshop-pack.png) | ![The finished helicopter parked on a red desert hill, a CH-47 flying past behind it](images/phase-11/workshop-finished-vehicle.png) | +| Name the pack and export a zip to share | The result is an ordinary content pack | + ## Phase 10 (`1.1.0-beta.6`) The rest of the equipment: smoke grenades, the GLTD target designator, the rangefinder, cargo diff --git a/docs/TESTING.md b/docs/TESTING.md index 83200234..968e5891 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -631,6 +631,55 @@ Forge 26.3 only. Start from a world made by beta.5, in survival. triangle and distance over the targets, a diamond at the screen edge when looking away; the other team sees nothing. Mark point shows a yellow diamond with coordinates for a minute. +## Phase 11 checklist (`1.1.0-beta.7`) + +Forge 26.3 only, in single player unless it says otherwise. The guide is +[docs/content/workshop.md](content/workshop.md). `examples/rework-example-pack` has a model to use. + +### Opening and importing + +- [ ] The drafting table has a Workshop button. It hands back what was in the table and opens the + workshop, which lists the vehicles in `mcheli/packs`. +- [ ] Drop an OBJ on the window: its path and a vehicle name fill in, and the pack id follows the + name. Create opens the editor with the model showing, the right way up, nose towards `+Z`. +- [ ] A PNG next to the OBJ shows as its texture, glass included; without one the model is grey. +- [ ] These are refused with a clear message: an `.fbx`, a binary file renamed to `.obj`, an OBJ with + a bad vertex number (the line is named), a name with capitals, a pack id already in use. +- [ ] A model made 16 times too large, or without `$body`, opens with a warning at the bottom. + +### Editor + +- [ ] Left drag turns the view, right drag moves it, the wheel zooms, R resets. Gizmos stay on their + parts through all of it, at every GUI scale. +- [ ] Add each part type for the kind: it appears in the list and on the model. A wing pylon or + propeller blades without a wing or hub ask for one first. +- [ ] Drag a seat's square and each of its arrows: it follows the mouse, snaps to 1/16 of a block, + moves freely with Shift held, and the numbers on the right follow. An arrow pointing at the + viewer does nothing rather than jump. +- [ ] Type a position, an axis and a number; a value that is not a number turns red and is not used. +- [ ] Rotors spin about the purple line when flown, wheels and gear hinge where placed. +- [ ] Mirror adds a copy across the middle. Delete removes a part; deleting a wing takes its pylons. +- [ ] Ctrl + Z steps back through all of that, one drag or one typed value at a time; Ctrl + Y + goes forward again. +- [ ] A part the model has no `$` object for is named at the bottom of the screen. +- [ ] Removing every seat shows that the game would reject the vehicle, and Test flight says so. + +### Tuning, test flight and saving + +- [ ] The Tuning tab lists only what the kind has. Defaults show dim; a changed value shows bright + and an emptied box goes back to the default. Out of range numbers turn red. +- [ ] Test flight saves and seats you in the vehicle. It flies with the tuned values and takes no + damage. Getting out puts you back where you started, removes the vehicle and reopens the editor + with undo intact. +- [ ] Test flight in a LAN world: another player gets the pack sent and sees the vehicle. +- [ ] On a dedicated server the button is off with a tooltip saying why; the rest still works. +- [ ] Closing with unsaved changes asks to save. Save, restart the game, reopen from the list: all + there. Comments added to the definition in a text editor survive another save. +- [ ] Export writes `mcheli/workshop/-.zip`. Put it in the `mcheli/packs` of another + game: the vehicle loads and flies. Opening it there makes `-edit` and leaves the zip alone. +- [ ] A saved pack on a dedicated server: every joining player downloads it and sees the vehicle. +- [ ] `EnginePosition` and `FuelTankPosition`: a wreck burns at the engine, a crash goes up at the tank. + ## Reporting problems Attach `logs/latest.log` (and the crash report, if any) with the helicopter name, what you did diff --git a/docs/content/aircraft.md b/docs/content/aircraft.md index 26c4e971..6cf42b64 100644 --- a/docs/content/aircraft.md +++ b/docs/content/aircraft.md @@ -247,6 +247,8 @@ the old damage values to the flattened item names. | `InventorySize = 18` | Slots in the cargo chest, up to 54. Sizes between rows round up to a whole row of 9. | | `Regeneration = true` | Mobs from seat 2 onwards heal over time. | | `Stealth = 0.5` | 0.0 to 1.0, default 0.0. Higher values make missile locks take longer and shorten lock range. | +| `EnginePosition = X, Y, Z` | Where a wreck burns. Not in the original; without it, the middle of the body. | +| `FuelTankPosition = X, Y, Z` | Where a crash explodes. Not in the original; without it, the vehicle's origin. | `BoundingBox = center X, center Y, center Z, width, height, damage multiplier` adds a hit box that only the mod's guns and missiles hit; blocks and entities ignore it. Turn on test mode to see it. diff --git a/docs/content/modeling.md b/docs/content/modeling.md index 9ff67dd6..de8e18c4 100644 --- a/docs/content/modeling.md +++ b/docs/content/modeling.md @@ -2,10 +2,9 @@ This guide covers the model side of a new vehicle: the part you build outside Minecraft. The vehicle itself (seats, rotors, wings, engine, weapons, flight values) is described by a definition -file, see [aircraft.md](aircraft.md). The vehicle workshop planned for 1.1.0 (see -[ROADMAP.md](../ROADMAP.md), phases 10 and 11) will let you do that second half in game by clicking on the -model instead of typing coordinates. The model rules below are the same either way, so a model -made today keeps working in the workshop. +file, see [aircraft.md](aircraft.md). The [workshop](workshop.md) does that second half in game, by +dragging parts on the model instead of typing coordinates. The model rules below are the same +either way. ## The short version @@ -89,7 +88,8 @@ definition or click in the workshop. * Back faces are culled, as in the original. A face whose normal points into the vehicle is invisible from outside. In Blender, show face orientation (Overlays > Face Orientation): every outside face should be blue. Recalculate outside (Shift + N) fixes most of them. -* Triangles and quads are fine; larger polygons should be triangulated before export. +* Triangles and quads are fine. Larger polygons are split into a fan of triangles, which only looks + right for flat, convex ones, so triangulate anything else before export. * Normals from the file are used for shading, so smooth or flat shading exported from your tool shows as you set it. @@ -123,10 +123,5 @@ install by dropping it into `mcheli/packs` and servers send to everyone who join [packs.md](packs.md), and the example pack in `examples/rework-example-pack`, whose Rework Scout follows this guide. -## What the workshop will do with the model - -Once the model is imported, the workshop shows it in 3D and asks you to connect each part to -what it does: click the `$blade0` hub to place the rotor, the seat cushions to place seats, the -engine and fuel tank to set where damage starts fires, the wing tips for lift, the gun barrels for -weapons, and drag boxes over the hull for hit boxes. It then writes the definition for you. A -model made with the names and pivots above needs no changes for that. +The [workshop](workshop.md) makes the pack for you: drop the OBJ on the game window, then place each +pivot on the model. It says which `$` objects a part you placed expects and the model lacks. diff --git a/docs/content/packs.md b/docs/content/packs.md index 92a4770c..dadc3fb7 100644 --- a/docs/content/packs.md +++ b/docs/content/packs.md @@ -8,6 +8,9 @@ player who joins gets the server's packs sent automatically. A pack is data only. The game refuses a pack that holds anything but the file types listed below, so installing or downloading one can never run code on your machine. +The easiest way to make one is the [workshop](workshop.md), in game. This page is the format itself, +for writing or changing a pack by hand. + The [example pack](../../examples/rework-example-pack) in this repository has one helicopter, the Rework Scout, and is the quickest way to start: copy it, rename it and change it. It is public domain (CC0 1.0), so what you build from it is entirely yours. diff --git a/docs/content/workshop.md b/docs/content/workshop.md new file mode 100644 index 00000000..7109c70d --- /dev/null +++ b/docs/content/workshop.md @@ -0,0 +1,122 @@ +# The vehicle workshop + +The workshop builds a vehicle in game: bring a model from Blockbench or Blender, place its seats, +rotors, wheels, weapons and hit boxes by dragging them on the model, tune how it flies, and try it +straight away. What comes out is an ordinary [content pack](packs.md), so it works everywhere a +pack does: other players can install it, and a server sends it to everyone who joins. + +![The workshop editor: a small helicopter on a grid, a hit box outlined around its cockpit, coloured handles on its parts, and the main rotor selected with arrows, its axis and its values in the side panel](../images/phase-11/workshop-editor.png) + +Textures, sounds and the pack browser come in a later beta (see the +[roadmap](../ROADMAP.md#phase-12-workshop-textures-sounds-and-sharing-110-beta8)). Until then a +vehicle uses the PNG that came with its model and the default sound of its kind. + +## Opening it + +Craft and place a drafting table, open it and press **Workshop**. The table's items go back to your +inventory first; the workshop needs no block once it is open. + +On the left are the vehicles in `mcheli/packs`. A vehicle in a folder pack opens as it is. A vehicle +in a zip opens from a copy with `-edit` added to the pack id, and the zip stays as it was; the copy +loads after the zip, so its version of the vehicle is the one the game uses. + +## Starting a vehicle + +1. Make the model following the [modeling guide](modeling.md): nose along +Z, Y up, one unit to a + block, the moving parts as `$` objects, exported as Wavefront OBJ. +2. Drop the OBJ on the game window, or paste its path. That fills in the vehicle's name, which you + can change. Names are lower case letters, digits, `-` and `_`, and they stay with the vehicle + for good, since worlds save them. Prefixing your own name keeps them from clashing with anyone + else's. +3. Pick the kind and press **Create**. + +The PNG named by the `.mtl` next to the OBJ comes along as the texture, or else a PNG with the same +name as the OBJ. With neither, the vehicle starts plain grey. + +A file the game cannot use is refused with the reason, such as another format with an `.obj` name, a +face using a vertex that does not exist (with its line number), or a file with no faces. Things that +load but probably look wrong are shown as warnings along the bottom of the editor: no `$body`, no +texture coordinates, or a size that suggests the export scale is off. Blockbench models often need +scaling by 1/16. + +## Moving around the editor + +| Input | Does | +|-------|------| +| Left drag on empty space | Turn the view | +| Right drag | Move the view | +| Wheel | Zoom | +| R | Back to the starting view | +| Ctrl + Z, Ctrl + Y | Undo, redo | +| Ctrl + S | Save | +| Delete | Delete the selected part | + +The grid is the ground the vehicle stands on, and `+Z` marks where the nose should point. + +## Placing parts + +The **Parts** tab has the list of parts on the left. Pick a type with `<` and `>` at the top and press +`+` to add one. Every part shows as a small square on the model, even inside the hull. + +Click a square, or a row in the list, to select it. The panel on the right then shows its values. +Drag the square to move it across the screen, or drag one of the three arrows to move it along that +axis (X red, Y green, Z blue). It snaps to a sixteenth of a block unless you hold Shift. A gunner +seat's camera and a wheel's steering pivot have a square of their own. + +**Mirror** adds a copy on the other side of the vehicle. The purple line through a selected rotor or +hinge is its axis. + +| In the list | Definition key | Notes | +|-------------|----------------|-------| +| Seat, Gunner seat, Fixed view seat | `AddSeat`, `AddGunnerSeat`, `AddFixRotSeat` | The first seat in the list is the pilot. | +| Camera, Camera turret | `CameraPosition`, `AddPartCamera` | | +| Rotor | `AddRotor` | Helicopters. Uses `$blade0`, `$blade1` in list order. | +| Propeller hub, Propeller blades | `AddPartRotor`, `AddBlade` | Planes. Blades attach to a hub. | +| Moving wing, Wing pylon | `AddPartWing`, `AddPartPylon` | Planes. A pylon attaches to a wing. | +| Flap or lever | `AddPartThrottle` | The format has no flaps as such: this is a part that turns with the throttle. | +| VTOL nozzle | `AddPartNozzle` | Planes. | +| Wheel, Cockpit wheel | `AddPartWheel`, `AddPartSteeringWheel` | | +| Ground contact | `SetWheelPos` | Tanks. Where the hull meets the ground. | +| Track, Track roller | `AddCrawlerTrack`, `AddTrackRoller` | Tanks. A track's outline is typed in as `y/z` points. | +| Landing gear (four sorts) | `AddPartLG`, `AddPartLGRev`, `AddPartLGHatch`, `AddPartSlideRotLG` | | +| Canopy, Hatch (each also sliding) | `AddPartCanopy`, `AddPartHatch`, `AddPartSlideCanopy`, `AddPartSlideHatch` | A sliding part has no spot of its own, only how far it travels. | +| Weapon, Turret weapon, Turret pivot | `AddWeapon`, `AddTurretWeapon`, `TurretPosition` | `<` and `>` step through the weapons the game has loaded. | +| Hit box | `BoundingBox` | Outlined on the model. Change its size in the panel. | +| Engine, Fuel tank | `EnginePosition`, `FuelTankPosition` | Where a wreck burns and where a crash goes up. | + +[aircraft.md](aircraft.md) explains every key. The workshop edits the definition one line at a time +and keeps everything else in the file as it was, comments and keys without a panel included, so you +can also open the file in a text editor and carry on in the workshop afterwards. + +A part the definition expects but the model has no `$` object for is named along the bottom of the +editor. + +## Tuning + +The **Tuning** tab holds the values that decide how the vehicle handles and how much it takes: top +speed, turn rates, gravity, fuel, hit points, armor and so on. Only the ones that apply to its kind +are listed. A dim name means the value is the default for the kind; emptying a box goes back to +the default. A box turns red when the number is outside what the game accepts, and the value stays +as it was until it is valid. + +## Test flight + +**Test flight** saves the vehicle, loads it and puts you in its pilot seat where you stand. Get out +as usual to come back to the editor where you left it, with your undo history; you are put back +where you started and the vehicle disappears. A test vehicle takes no damage and is not saved with +the world. + +This works in single player and in a LAN world you host. On a dedicated server the button is off, +because the server does not have your pack. Save or export it and have the server's owner install it. + +## Saving and sharing + +**Save** writes the pack to `mcheli/packs/`, as a folder you can open and read. The game +loads it from the next start or `/mcheli reload`. + +The **Pack** tab sets the vehicle's display name and the pack's name, author, version and description. +Raise the version when you share a new build. + +**Export** saves and writes `mcheli/workshop/-.zip`, checked the way the game checks +any pack. That zip is what you give to other players or a server. Only share what you have the +right to. diff --git a/docs/images/phase-11/workshop-editor.png b/docs/images/phase-11/workshop-editor.png new file mode 100644 index 00000000..10441d6e Binary files /dev/null and b/docs/images/phase-11/workshop-editor.png differ diff --git a/docs/images/phase-11/workshop-finished-vehicle.png b/docs/images/phase-11/workshop-finished-vehicle.png new file mode 100644 index 00000000..b5c4ca28 Binary files /dev/null and b/docs/images/phase-11/workshop-finished-vehicle.png differ diff --git a/docs/images/phase-11/workshop-import.png b/docs/images/phase-11/workshop-import.png new file mode 100644 index 00000000..5523394a Binary files /dev/null and b/docs/images/phase-11/workshop-import.png differ diff --git a/docs/images/phase-11/workshop-pack.png b/docs/images/phase-11/workshop-pack.png new file mode 100644 index 00000000..e8475915 Binary files /dev/null and b/docs/images/phase-11/workshop-pack.png differ diff --git a/docs/images/phase-11/workshop-test-flight.png b/docs/images/phase-11/workshop-test-flight.png new file mode 100644 index 00000000..cba915fb Binary files /dev/null and b/docs/images/phase-11/workshop-test-flight.png differ diff --git a/docs/images/phase-11/workshop-tuning.png b/docs/images/phase-11/workshop-tuning.png new file mode 100644 index 00000000..0f2b9a47 Binary files /dev/null and b/docs/images/phase-11/workshop-tuning.png differ diff --git a/gradle.properties b/gradle.properties index 9fdae84a..c4ef6520 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ net.minecraftforge.gradle.merge-source-sets=true mod_id=mcheli mod_name=MCHeli Rework mod_group=com.jnhprojects.mcheli -mod_version=1.1.0-beta.6 +mod_version=1.1.0-beta.7 mod_authors=JayMar921 (rework), EMB4 (original author), Fureniku (CurseForge maintainer) mod_license=MCHeli Rework Licence 1.0 (see LICENSE) diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java index 0e9f82ac..7fd3b542 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -23,6 +23,7 @@ import com.jnhprojects.mcheli.client.screen.UavStationScreen; import com.jnhprojects.mcheli.client.screen.VehicleScreen; import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; import com.jnhprojects.mcheli.entity.ClientAccess; import com.jnhprojects.mcheli.platform.Platform; import com.jnhprojects.mcheli.registry.ModBlockEntities; @@ -148,6 +149,7 @@ public static void clientTickEnd() { HudRenderer.tick(); EngineSoundPlayer.tick(); TargetMarkers.tick(); + WorkshopClient.tick(); while (KeyBindings.TEAM_MANAGER.consumeClick()) { openTeamManager(); } @@ -296,6 +298,7 @@ public static void loggedIn(LocalPlayer player) { public static void loggedOut() { ClientPacks.INSTANCE.loggedOut(); + WorkshopClient.loggedOut(); Markers.clear(); } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java index c19c8819..7a3cdc90 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -40,6 +40,8 @@ public final class RuntimeAssets { /** Searched in order, so the pack loaded last comes first. Replaced as a whole. */ private static volatile List roots = List.of(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ private static final Map TEXTURES = new ConcurrentHashMap<>(); @@ -54,6 +56,11 @@ public static void setRoots(List folders) { roots = List.copyOf(folders); } + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ public static @Nullable InputStream open(Identifier id) throws IOException { Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); @@ -130,12 +137,24 @@ public static void clearTextures() { } private static @Nullable Path find(String path) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } for (Path root : roots) { - Path file = root.resolve(path).normalize(); - if (file.startsWith(root) && Files.isRegularFile(file)) { + Path file = inRoot(root, path); + if (file != null) { return file; } } return null; } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java index 09defd8e..d2dd770d 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -51,6 +51,8 @@ public class AircraftRenderState extends EntityRenderState { public boolean localFirstPerson; /** Shade for the unlit glass, from the light at the vehicle. */ public int glassColor = 0xFFFFFFFF; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ public WeaponPartState[] weaponParts = new WeaponPartState[0]; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java index 0c2f9a3b..90f739dd 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -488,8 +488,10 @@ private void submit(List groups) { collector.submitCustomGeometry(pose, opaqueType, (p, buffer) -> MeshRenderer.emit(split.opaque(), p, buffer, light, overlay)); } if (!split.glass().isEmpty()) { - collector.submitCustomGeometry(pose, glassType, - (p, buffer) -> MeshRenderer.emitFrontFaces(split.glass(), p, buffer, light, overlay, glassColor)); + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + collector.submitCustomGeometry(pose, glassType, state.inGui + ? (p, buffer) -> MeshRenderer.emit(split.glass(), p, buffer, light, overlay, glassColor) + : (p, buffer) -> MeshRenderer.emitFrontFaces(split.glass(), p, buffer, light, overlay, glassColor)); } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java index cca47460..ec38e26d 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.client.screen; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; @@ -12,6 +13,7 @@ import com.jnhprojects.mcheli.registry.ModItems; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.recipebook.RecipeCollection; import net.minecraft.client.renderer.RenderPipelines; @@ -91,9 +93,18 @@ protected void init() { .bounds(leftPos + 210, topPos + 210, 60, 20).build()); nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); selectList(list); } + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.gui.setScreen(new WorkshopHomeScreen()); + } + /** Every MCHeli recipe the recipe book holds, sorted into the five lists by what it makes. */ private void collectRecipes() { lists.clear(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..64d9a579 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +/** + * Takes the player from the editor to a test flight and back. The screen stays alive in between, so + * the undo history and the view are as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().gui.setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + public static void tick() { + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.gui.screen() == null) { + mc.gui.setScreen(screen); + } + } + } + + public static void loggedOut() { + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..c036d315 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,199 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.gui.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().name() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.gui.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 10, TEXT); + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.text(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.text(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..08bcfe9e --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1212 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Matrix3x2fStack; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

Gizmos are plain GUI drawing at the points {@link WorkshopCamera#project} gives. They always + * show, even when the part is inside the hull, which is where most seats and pivots are. + * + *

Left drag on empty space turns the view, right drag moves it, the wheel zooms. Dragging a + * handle moves the part in the plane of the screen and an arrow along its axis, snapping to a + * sixteenth of a block unless Shift is held. + */ +public class WorkshopScreen extends Screen { + private static final int LEFT = 112; + private static final int RIGHT = 144; + private static final int TOP = 26; + private static final int BOTTOM = 26; + private static final int LIST_ROW = 16; + private static final int FIELD_ROW = 20; + private static final int LABEL_ROW = 10; + + private static final int PANEL = 0xFF181818; + private static final int VIEW = 0xFF2A2F36; + private static final int GRID = 0xFF3A414A; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int WARNING = 0xFFFFC040; + private static final int INVALID = 0xFFFF6060; + private static final int X_COLOR = 0xFFFF5050; + private static final int Y_COLOR = 0xFF60E060; + private static final int Z_COLOR = 0xFF5090FF; + private static final int SELECTED = 0xFFFFFFFF; + private static final int HIT_BOX = 0xFF00E0FF; + private static final int AXIS = 0xFFFF60FF; + + private static final int HANDLE_RADIUS = 3; + private static final double PICK_DISTANCE = 6.0; + private static final double ARROW_LENGTH = 34.0; + private static final double SNAP = 16.0; + private static final int GRID_HALF_SIZE = 16; + private static final int GRID_STEP = 2; + private static final double ZOOM_STEP = 1.15; + + private enum Tab { + PARTS, TUNING, PACK + } + + private enum Drag { + NONE, ORBIT, PAN, PLANE, AXIS_X, AXIS_Y, AXIS_Z + } + + private record Label(Component text, int x, int y, int color) { + } + + private record WrappedLine(FormattedCharSequence text, int x, int y) { + } + + private record MergeKey(int line, String field, int component) { + } + + private final WorkshopProject project; + private final WorkshopCamera camera = new WorkshopCamera(); + private final EditHistory> history = new EditHistory<>(); + private final List