diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fab0ba2..b504b4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). ## [Unreleased] +## [1.1.0-beta.8] - 2026-11-06 + +Phase 12 of the road to 1.1.0: the second 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.7 load as before. +Players and servers need the same version, since the network protocol changed (11). + +### Added + +- Workshop Paint tab: import a PNG for the whole vehicle or for any part, by dropping it on the window + or pasting its path, and see it on the model straight away. Painting itself happens in your own image + editor. Files that are not real PNGs are refused with the reason. +- `PartTexture` definition key: draws one object of a model with its own texture, whatever paint the + vehicle wears. An object named `$cockpit` now draws with the hull, so a cockpit can have its own. +- Workshop Sound tab: import an OGG for the engine, the rotor or any of the vehicle's weapons, play it, + set its volume or pitch, and drag a pitch curve. OGG files the game cannot play, such as Opus, are + refused with the reason. +- `SoundPitchCurve`, `RotorSound`, `RotorSoundVolume`, `RotorSoundPitchCurve` and `WeaponSound` + definition keys. A second loop plays alongside the engine, loops can follow the throttle along a + curve, and a vehicle can have its own sound for a weapon without changing the weapon's file. +- Pack browser, from the Packs button in the workshop: every pack you have or the server sent, with its + author, version, size and contents, and the reason for any that cannot be used. Drop a zip on it to + install the pack. +- Author credits: a pack vehicle's item names its pack and author, and so does `/mcheli packs`. +- Sending a vehicle to a server: Send to server in the workshop offers your pack to the server's + operators, who decide with `/mcheli pending`, `/mcheli approve` and `/mcheli reject`. Approved packs + reach every player with no restart. This is off unless the server sets `packs.allowSubmissions`; + `packs.requireApproval` is on by default. + +### Fixed + +- A sound a content pack brought for a weapon or anything else played once by the server was silent. Only + engine loops could use pack sounds. + +### Changed + +- Network protocol 11. +- The roadmap's texture brush is dropped: textures are imported per part as PNGs. + ## [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 @@ -667,6 +705,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.8]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.8 [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 diff --git a/README.md b/README.md index 82ca27a1..716d035f 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ 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 11 (`1.1.0-beta.7`). Every helicopter, plane, +> **Status:** on the way to 1.1.0, now at phase 12 (`1.1.0-beta.8`). 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. The vehicle -> workshop builds new vehicles in game from a Blockbench or Blender model. Betas ship the +> workshop builds new vehicles in game from a Blockbench or Blender model, with their own textures +> and sounds, and sends them to a server for its operators to approve. 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). @@ -33,13 +34,14 @@ Phase 4 clips (click a picture to open the video): ## Screenshots -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). +The vehicle workshop in a development build (`1.1.0-beta.8`): bring a model +from Blockbench or Blender, place its parts on it, tune it and fly it, then give it textures and +sounds of its own. Every earlier phase is in the [screenshot gallery](docs/SCREENSHOTS.md). | | | |---|---| +| ![The Paint tab: the model's parts listed on the left, the helicopter in the middle with blue rotor blades, a red cockpit panel and a green hull, and the blade texture previewed on the right](docs/images/phase-12/workshop-paint.png) | ![The Sound tab: Engine, Rotor and a weapon listed on the left, and on the right the engine's sound file, Play and Clear buttons, its volume, and a pitch curve with five points](docs/images/phase-12/workshop-sound.png) | +| A PNG per part, showing on the model straight away | An OGG for the engine, with its pitch following the throttle along a curve | | ![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) | diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java index 680e688d..87e66c63 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java @@ -144,6 +144,7 @@ void folder(ContentSource source, String folder, Function if (result.isValid()) { T def = result.definition(); def.setPack(source.pack()); + def.setCredit(source.manifest()); if (def instanceof AircraftDefinition vehicle) { fillHull(source, vehicle); } diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java b/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java index 73054202..2f1412e6 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java @@ -1,5 +1,8 @@ package com.jnhprojects.mcheli.content; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import org.jspecify.annotations.Nullable; + import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; @@ -15,6 +18,7 @@ public abstract class Definition { private final String name; private final Map> handlers = new HashMap<>(); private String pack = ""; + private @Nullable PackManifest credit; protected Definition(String name) { this.name = name; @@ -38,6 +42,15 @@ void setPack(String pack) { this.pack = pack; } + /** What the pack this came from says about itself, for credits; {@code null} for the import. */ + public @Nullable PackManifest credit() { + return credit; + } + + void setCredit(@Nullable PackManifest credit) { + this.credit = credit; + } + protected abstract void registerKeys(KeyTable keys); /** 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 db0e6a2f..2f6a50b4 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 @@ -13,6 +13,7 @@ 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.model.PartTextures; import org.jspecify.annotations.Nullable; import java.util.ArrayList; @@ -51,6 +52,8 @@ public abstract class AircraftDefinition extends Definition { public final Map localizedNames = new LinkedHashMap<>(); public String category = "zzz"; public final List textures = new ArrayList<>(); + /** Texture names by part, which a part draws with whatever paint the vehicle wears. */ + public final Map partTextures = new LinkedHashMap<>(); public boolean smoothShading = true; public boolean hideEntity; public float particlesScale = 1.0F; @@ -164,6 +167,14 @@ public abstract class AircraftDefinition extends Definition { public float soundRange; public float soundVolume = 1.0F; public float soundPitch = 1.0F; + /** How the engine's pitch follows the throttle, or {@code null} for what the kind does. */ + public @Nullable PitchCurve soundPitchCurve; + /** A second loop alongside the engine, or empty for none. */ + public String rotorSound = ""; + public float rotorSoundVolume = 1.0F; + public @Nullable PitchCurve rotorSoundPitchCurve; + /** This vehicle's own sounds for weapons, by weapon name. */ + public final Map weaponSounds = new LinkedHashMap<>(); // Cargo racks public final List racks = new ArrayList<>(); @@ -330,6 +341,13 @@ protected void registerKeys(KeyTable keys) { }); keys.put("Category", v -> category = v.toUpperCase(Locale.ROOT).replaceAll("[,;:]", ".").replaceAll("[ \t]", "")); keys.put("AddTexture", v -> textures.add(lower(v))); + keys.put("PartTexture", v -> { + String[] s = split(v); + if (s.length != 2) { + throw new IllegalArgumentException("PartTexture needs a part and a texture"); + } + partTextures.put(PartTextures.partName(s[0]), lower(s[1])); + }); keys.put("SmoothShading", v -> smoothShading = toBool(v)); keys.put("HideEntity", v -> hideEntity = toBool(v)); keys.put("ParticlesScale", v -> particlesScale = toFloat(v, 0.0F, 50.0F)); @@ -525,6 +543,18 @@ protected void registerKeys(KeyTable keys) { keys.put("SoundRange", v -> soundRange = toFloat(v, 1.0F, 1000.0F)); keys.put("SoundVolume", v -> soundVolume = toFloat(v, 0.0F, 10.0F)); keys.put("SoundPitch", v -> soundPitch = toFloat(v, 0.0F, 10.0F)); + keys.put("SoundPitchCurve", v -> soundPitchCurve = PitchCurve.parse(v)); + keys.put("RotorSound", v -> rotorSound = lower(v)); + keys.put("RotorSoundVolume", v -> rotorSoundVolume = toFloat(v, 0.0F, 10.0F)); + keys.put("RotorSoundPitchCurve", v -> rotorSoundPitchCurve = PitchCurve.parse(v)); + keys.put("WeaponSound", v -> { + String[] s = split(v); + if (s.length < 2) { + throw new IllegalArgumentException("WeaponSound needs a weapon and a sound"); + } + Float pitch = s.length >= 3 ? toFloat(s[2], PitchCurve.MIN_PITCH, PitchCurve.MAX_PITCH) : null; + weaponSounds.put(lower(s[0]), new WeaponSound(lower(s[1]), pitch)); + }); keys.put("AddRecipe", v -> { shapedRecipe = true; diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/PitchCurve.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/PitchCurve.java new file mode 100644 index 00000000..eb12ed70 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/PitchCurve.java @@ -0,0 +1,101 @@ +package com.jnhprojects.mcheli.content.aircraft; + +import java.util.Locale; + +/** + * How an engine or rotor loop's pitch follows the throttle, from {@code SoundPitchCurve} and + * {@code RotorSoundPitchCurve}: points of {@code throttle:pitch} with straight lines between them. + *
+ * SoundPitchCurve = 0:0.6, 0.5:0.9, 1:1.4
+ * 
+ * Throttle runs from 0 to 1. Pitch stays within {@value #MIN_PITCH} to {@value #MAX_PITCH}, all the + * sound engine will play, so a curve never promises something that cannot be heard. + */ +public final class PitchCurve { + public static final float MIN_PITCH = 0.5F; + public static final float MAX_PITCH = 2.0F; + + private final float[] throttle; + private final float[] pitch; + + private PitchCurve(float[] throttle, float[] pitch) { + this.throttle = throttle; + this.pitch = pitch; + } + + /** + * @throws IllegalArgumentException when the text is not a curve; the message is meant for the + * pack's author + */ + public static PitchCurve parse(String text) { + String[] points = text.split(","); + if (points.length < 2) { + throw new IllegalArgumentException("a pitch curve needs at least two throttle:pitch points"); + } + float[] x = new float[points.length]; + float[] y = new float[points.length]; + for (int i = 0; i < points.length; i++) { + String[] parts = points[i].split(":"); + if (parts.length != 2) { + throw new IllegalArgumentException("\"" + points[i].trim() + "\" is not throttle:pitch"); + } + x[i] = Float.parseFloat(parts[0].trim()); + y[i] = Math.clamp(Float.parseFloat(parts[1].trim()), MIN_PITCH, MAX_PITCH); + if (x[i] < 0.0F || x[i] > 1.0F) { + throw new IllegalArgumentException("throttle " + parts[0].trim() + " is outside 0 to 1"); + } + if (i > 0 && x[i] <= x[i - 1]) { + throw new IllegalArgumentException("the throttle values of a pitch curve must rise from left to right"); + } + } + return new PitchCurve(x, y); + } + + /** The pitch at a throttle. Beyond its first and last points the curve stays level. */ + public float at(float value) { + if (value <= throttle[0]) { + return pitch[0]; + } + for (int i = 1; i < throttle.length; i++) { + if (value <= throttle[i]) { + float f = (value - throttle[i - 1]) / (throttle[i] - throttle[i - 1]); + return pitch[i - 1] + (pitch[i] - pitch[i - 1]) * f; + } + } + return pitch[pitch.length - 1]; + } + + public int size() { + return throttle.length; + } + + public float throttle(int point) { + return throttle[point]; + } + + public float pitch(int point) { + return pitch[point]; + } + + /** The same curve with one point at another pitch. */ + public PitchCurve withPitch(int point, float value) { + float[] y = pitch.clone(); + y[point] = Math.clamp(value, MIN_PITCH, MAX_PITCH); + return new PitchCurve(throttle, y); + } + + /** The curve as a definition value, which {@link #parse} reads back. */ + public String text() { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < throttle.length; i++) { + out.append(i > 0 ? ", " : "").append(number(throttle[i])).append(':').append(number(pitch[i])); + } + return out.toString(); + } + + private static String number(float value) { + String text = String.format(Locale.ROOT, "%.3f", value); + text = text.replaceAll("0+$", ""); + return text.endsWith(".") ? text.substring(0, text.length() - 1) : text; + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponSound.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponSound.java new file mode 100644 index 00000000..e6c9ff3d --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponSound.java @@ -0,0 +1,14 @@ +package com.jnhprojects.mcheli.content.aircraft; + +import org.jspecify.annotations.Nullable; + +/** + * What one weapon sounds like on this vehicle, from {@code WeaponSound = weapon, sound[, pitch]}. + * Weapon files are shared between vehicles, so a vehicle with its own gun sound says so here rather + * than in the weapon's file. + * + * @param sound the sound's name, as in {@code sounds/.ogg} + * @param pitch the pitch to play it at, or {@code null} for what the weapon's file says + */ +public record WeaponSound(String sound, @Nullable Float pitch) { +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java index eed97835..8dbd64e6 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java @@ -235,7 +235,7 @@ public Path assets() { } public ContentSource contentSource() { - return new ContentSource(id(), assets()); + return new ContentSource(id(), assets(), manifest); } /** Paths of the pack's files relative to its top, in order. */ diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java index cbfe6c7d..de346439 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java @@ -1,19 +1,27 @@ package com.jnhprojects.mcheli.content.pack; +import org.jspecify.annotations.Nullable; + import java.nio.file.Path; /** * One place definitions are read from: the {@code assets/mcheli} folder of a content pack, or of * the imported 1.0.3 content. * - * @param pack id of the pack, {@link #ORIGINAL} for the import - * @param assets the {@code assets/mcheli} folder, possibly inside a zip file system; it need not - * exist + * @param pack id of the pack, {@link #ORIGINAL} for the import + * @param assets the {@code assets/mcheli} folder, possibly inside a zip file system; it need not + * exist + * @param manifest what the pack says about itself, for crediting its author, or {@code null} for + * the import */ -public record ContentSource(String pack, Path assets) { +public record ContentSource(String pack, Path assets, @Nullable PackManifest manifest) { /** Pack id of the imported 1.0.3 content. No content pack may use it. */ public static final String ORIGINAL = "original"; + public ContentSource(String pack, Path assets) { + this(pack, assets, null); + } + public boolean isOriginal() { return ORIGINAL.equals(pack); } diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PendingPacks.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PendingPacks.java new file mode 100644 index 00000000..37791eca --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PendingPacks.java @@ -0,0 +1,245 @@ +package com.jnhprojects.mcheli.content.pack; + +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Packs players have sent a server, waiting for an operator in {@code /mcheli/pending}. + * + *

This folder takes files from any player, so nothing about a submission is trusted. Files are + * named by content hash alone, never by anything the player chose. A pack is opened through the same + * checks as any other before it is kept, which bounds its file count and unpacked size and refuses + * anything but definitions, models, textures and sounds. A player has one submission at a time, a + * new one replacing the last, and {@value #MAX_PENDING} wait at most, so the server's size limit for + * a pack also limits what this folder can hold. + * + *

Installing moves the zip as it is, so what goes live is the same bytes the operator reviewed. + */ +public final class PendingPacks { + public static final int MAX_PENDING = 16; + + private static final Pattern FILE = Pattern.compile("[0-9a-f]{64}\\.zip"); + private static final String REPLACED = ".replaced"; + + private final Path folder; + private final Map read = new HashMap<>(); + + public PendingPacks(Path folder) { + this.folder = folder; + } + + /** + * A pack waiting for an operator. + * + * @param submitter the name of the player who sent it, which unlike the manifest's author the + * server knows to be true + * @param submitterId that player's id + * @param definitions the definition files it holds, for the operator to look over + */ + public record Submission(PackManifest manifest, String hash, long size, String submitter, UUID submitterId, + List definitions, Path file) { + public String id() { + return manifest.id(); + } + } + + /** What is waiting, oldest first. A file that cannot be read is left out, and stays on disk. */ + public synchronized List list() throws IOException { + List files; + try (Stream list = Files.list(folder)) { + files = list.filter(p -> FILE.matcher(p.getFileName().toString()).matches()) + .sorted(Comparator.comparingLong(PendingPacks::modified).thenComparing(Comparator.naturalOrder())) + .toList(); + } catch (NoSuchFileException e) { + return List.of(); + } + List found = new ArrayList<>(); + for (Path file : files) { + try { + found.add(read(file)); + } catch (IOException | PackException | IllegalArgumentException e) { + // Damaged by hand or by a full disk. The operator can look at it in the folder. + } + } + return List.copyOf(found); + } + + private static long modified(Path file) { + try { + return Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + return 0L; + } + } + + /** A file named by its hash never changes, and opening one hashes it, so each is only read once. */ + private Submission read(Path file) throws IOException, PackException { + Submission known = read.get(file); + if (known != null) { + return known; + } + Properties about = new Properties(); + try (InputStream in = Files.newInputStream(about(file))) { + about.load(in); + } + try (ContentPack pack = ContentPack.open(file)) { + Submission submission = new Submission(pack.manifest(), pack.hash(), Files.size(file), about.getProperty("submitter", "?"), + UUID.fromString(about.getProperty("submitterId", "")), definitions(pack), file); + read.put(file, submission); + return submission; + } + } + + private static List definitions(ContentPack pack) { + String assets = "assets/mcheli/"; + List definitions = new ArrayList<>(); + for (String path : pack.files()) { + if (path.startsWith(assets) && path.endsWith(".txt")) { + definitions.add(path.substring(assets.length())); + } + } + return List.copyOf(definitions); + } + + private static Path about(Path file) { + String name = file.getFileName().toString(); + return file.resolveSibling(name.substring(0, name.length() - ".zip".length()) + ".properties"); + } + + /** The submission with this pack id, or {@code null}. Ids are unique while waiting. */ + public @Nullable Submission find(String id) throws IOException { + for (Submission submission : list()) { + if (submission.id().equals(id)) { + return submission; + } + } + return null; + } + + /** + * Checks and keeps what a player sent, replacing that player's last submission. + * + * @throws PackException when it is not a usable pack or there is no room; the message is meant + * for the player + */ + public synchronized Submission store(byte[] zip, String player, UUID playerId) throws IOException, PackException { + Files.createDirectories(folder); + Path part = folder.resolve(playerId + ".part"); + Files.write(part, zip); + try { + PackManifest manifest; + String hash; + try (ContentPack pack = ContentPack.open(part)) { + manifest = pack.manifest(); + hash = pack.hash(); + } + List waiting = list(); + Submission previous = null; + for (Submission other : waiting) { + if (other.submitterId().equals(playerId)) { + previous = other; + } else if (other.id().equals(manifest.id())) { + throw new PackException("%s already has a pack called %s waiting for an operator. Pick another pack id." + .formatted(other.submitter(), manifest.id())); + } + } + if (previous == null && waiting.size() >= MAX_PENDING) { + throw new PackException("%d packs are already waiting for an operator. Try again later.".formatted(MAX_PENDING)); + } + if (previous != null) { + remove(previous); + } + Path file = folder.resolve(hash + ".zip"); + Properties about = new Properties(); + about.setProperty("submitter", player); + about.setProperty("submitterId", playerId.toString()); + try (OutputStream out = Files.newOutputStream(about(file))) { + about.store(out, null); + } + Files.move(part, file, StandardCopyOption.REPLACE_EXISTING); + return read(file); + } finally { + Files.deleteIfExists(part); + } + } + + /** Drops a submission. */ + public synchronized void remove(Submission submission) throws IOException { + read.remove(submission.file()); + Files.deleteIfExists(submission.file()); + Files.deleteIfExists(about(submission.file())); + } + + /** + * Moves a submission into {@code mcheli/packs}, taking the place of the zip of a pack with the + * same id. A zip in use stays open and on Windows cannot be overwritten, but it can be renamed: + * the old file moves aside and goes away in {@link #cleanReplaced} once nothing has it open. + * + * @return the installed zip + * @throws PackException when the pack with that id is a folder, which is someone's working copy + * and has to be removed by hand + */ + public synchronized Path install(Submission submission, Path packs) throws IOException, PackException { + Files.createDirectories(packs); + Path existing = null; + PackFolder.Scan scan = PackFolder.scan(packs); + for (ContentPack pack : scan.packs()) { + if (pack.id().equals(submission.id())) { + existing = pack.source(); + } + pack.close(); + } + if (existing != null && Files.isDirectory(existing)) { + throw new PackException("%s is a folder in mcheli/packs, so it may hold someone's work. Remove it by hand first." + .formatted(existing.getFileName())); + } + String shortHash = ContentPack.shortHash(submission.hash()); + if (existing != null) { + Files.move(existing, existing.resolveSibling(existing.getFileName() + REPLACED + "-" + shortHash), + StandardCopyOption.REPLACE_EXISTING); + } + Path target = packs.resolve(submission.id() + ".zip"); + if (Files.exists(target)) { + // Some other pack's file happens to have this name; the id inside is what counts. + target = packs.resolve(submission.id() + "-" + shortHash + ".zip"); + } + Files.move(submission.file(), target); + read.remove(submission.file()); + Files.deleteIfExists(about(submission.file())); + return target; + } + + /** Deletes the zips {@link #install} moved aside. One still open stays until a later try. */ + public static void cleanReplaced(Path packs) { + try (Stream list = Files.list(packs)) { + for (Path file : (Iterable) list::iterator) { + if (file.getFileName().toString().toLowerCase(Locale.ROOT).contains(".zip" + REPLACED)) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + // Still in use. The pack scan ignores it, so it can wait. + } + } + } + } catch (IOException e) { + // No folder, so nothing to clean. + } + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/model/PartTextures.java b/common/src/main/java/com/jnhprojects/mcheli/model/PartTextures.java new file mode 100644 index 00000000..14bf6ee2 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/model/PartTextures.java @@ -0,0 +1,69 @@ +package com.jnhprojects.mcheli.model; + +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Sorts a part's objects by the texture each one draws with, for {@code PartTexture}. A {@code $} + * object owns the plain objects behind it, so they take its texture unless they have their own. + */ +public final class PartTextures { + private PartTextures() { + } + + /** + * Objects that draw with one texture. + * + * @param texture its name, or {@code null} for the vehicle's current paint + */ + public record Bucket(@Nullable String texture, List groups) { + } + + /** The name {@code PartTexture} knows an object by: lower case, without the {@code $}. */ + public static String partName(String objectName) { + String lower = objectName.toLowerCase(Locale.ROOT); + return lower.startsWith("$") ? lower.substring(1) : lower; + } + + /** @param overrides texture names by {@linkplain #partName part name} */ + public static List assign(List groups, Map overrides) { + Map> byTexture = new LinkedHashMap<>(); + List paint = new ArrayList<>(); + String inherited = null; + for (MeshGroup group : groups) { + String own = overrides.get(partName(group.name())); + if (group.isPartHeader()) { + inherited = own; + } + String texture = own != null ? own : inherited; + (texture != null ? byTexture.computeIfAbsent(texture, t -> new ArrayList<>()) : paint).add(group); + } + List buckets = new ArrayList<>(); + if (!paint.isEmpty()) { + buckets.add(new Bucket(null, List.copyOf(paint))); + } + byTexture.forEach((texture, list) -> buckets.add(new Bucket(texture, List.copyOf(list)))); + return List.copyOf(buckets); + } + + /** + * The part names of a model's objects in file order, for picking one in the workshop. The name + * an OBJ gives everything before its first group means nothing, so it is left out. + */ + public static List names(Mesh mesh) { + Set names = new LinkedHashSet<>(); + for (MeshGroup group : mesh.groups()) { + if (!group.name().equalsIgnoreCase("Default")) { + names.add(partName(group.name())); + } + } + return List.copyOf(names); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/AssetImport.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/AssetImport.java new file mode 100644 index 00000000..46ec605d --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/AssetImport.java @@ -0,0 +1,113 @@ +package com.jnhprojects.mcheli.workshop; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +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.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * Checks a PNG or OGG before the workshop copies it into a pack, and says what is wrong in terms its + * author can act on. A pack holding a bad file would still load, then quietly show or play nothing. + */ +public final class AssetImport { + /** Servers allow 8 MB for a whole pack by default, so one file should stay well under it. */ + private static final long LARGE_FILE = 2L << 20; + private static final int LARGE_TEXTURE = 2048; + private static final int MAX_TEXTURE = 8192; + private static final byte[] PNG_SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + /** The signature, then the IHDR chunk's length and name, then its width and height. */ + private static final int PNG_SIZE_OFFSET = 16; + private static final int PNG_HEADER_BYTES = 24; + private static final byte[] OGG_SIGNATURE = {'O', 'g', 'g', 'S'}; + private static final int OGG_FIRST_PAGE_BYTES = 64; + + private AssetImport() { + } + + /** A usable PNG, with what its author may want to fix. */ + public record Texture(int width, int height, List warnings) { + } + + /** @throws WorkshopException when the game could not use the file */ + public static Texture texture(Path file) throws WorkshopException { + String name = checkFile(file, ".png", "a PNG image"); + try (InputStream in = Files.newInputStream(file)) { + byte[] head = in.readNBytes(PNG_HEADER_BYTES); + if (head.length < PNG_HEADER_BYTES || !startsWith(head, PNG_SIGNATURE)) { + throw new WorkshopException(name + " is not really a PNG. Save it again as PNG from your image editor."); + } + DataInputStream size = new DataInputStream(new ByteArrayInputStream(head, PNG_SIZE_OFFSET, 8)); + int width = size.readInt(); + int height = size.readInt(); + if (width <= 0 || height <= 0 || width > MAX_TEXTURE || height > MAX_TEXTURE) { + throw new WorkshopException("%s is %d by %d. A texture can be at most %d by %d.".formatted(name, width, height, MAX_TEXTURE, MAX_TEXTURE)); + } + List warnings = new ArrayList<>(); + if (Math.max(width, height) > LARGE_TEXTURE) { + warnings.add("%s is %d by %d, which costs a lot of video memory for every player.".formatted(name, width, height)); + } + warnAboutSize(file, name, warnings); + return new Texture(width, height, List.copyOf(warnings)); + } catch (IOException e) { + throw new WorkshopException("Could not read %s: %s".formatted(name, e)); + } + } + + /** + * Minecraft plays Ogg Vorbis only. An OGG file can hold other codecs, and those fail silently + * when they play, so the codec is checked here. + * + * @return what the author may want to fix + * @throws WorkshopException when the game could not use the file + */ + public static List sound(Path file) throws WorkshopException { + String name = checkFile(file, ".ogg", "an OGG Vorbis sound"); + try (InputStream in = Files.newInputStream(file)) { + byte[] head = in.readNBytes(OGG_FIRST_PAGE_BYTES); + if (!startsWith(head, OGG_SIGNATURE)) { + throw new WorkshopException(name + " is not really an OGG file. Export it again as OGG Vorbis."); + } + String first = new String(head, StandardCharsets.ISO_8859_1); + if (first.contains("OpusHead")) { + throw new WorkshopException(name + " holds Opus audio, which the game cannot play. Export it again as OGG Vorbis."); + } + if (!first.contains("vorbis")) { + throw new WorkshopException(name + " does not hold Vorbis audio, which is all the game can play. Export it again as OGG Vorbis."); + } + List warnings = new ArrayList<>(); + warnAboutSize(file, name, warnings); + return List.copyOf(warnings); + } catch (IOException e) { + throw new WorkshopException("Could not read %s: %s".formatted(name, e)); + } + } + + private static String checkFile(Path file, String extension, String what) throws WorkshopException { + if (!Files.isRegularFile(file)) { + throw new WorkshopException("No file at " + file); + } + String name = file.getFileName().toString(); + if (!name.toLowerCase(Locale.ROOT).endsWith(extension)) { + throw new WorkshopException("%s is not %s.".formatted(name, what)); + } + return name; + } + + private static void warnAboutSize(Path file, String name, List warnings) throws IOException { + long size = Files.size(file); + if (size > LARGE_FILE) { + warnings.add("%s is %.1f MB. Servers allow 8 MB per pack unless their config says otherwise.".formatted(name, size / 1048576.0)); + } + } + + private static boolean startsWith(byte[] bytes, byte[] prefix) { + return bytes.length >= prefix.length && Arrays.equals(bytes, 0, prefix.length, prefix, 0, prefix.length); + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java index 117082c8..3820cd96 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/DefinitionDocument.java @@ -113,6 +113,53 @@ public void set(String key, @Nullable String value) { } } + /** + * The value of a key that repeats, one line per thing it is about, such as + * {@code PartTexture = blade0, my-blade}: the line whose first value is {@code first}. + */ + public @Nullable String keyed(String key, String first) { + Entry entry = lastKeyed(key, first); + return entry != null ? entry.value() : null; + } + + /** + * Sets the line of a repeating key whose first value is {@code first}, or adds one at the end. + * With {@code null} every such line goes. + */ + public void setKeyed(String key, String first, @Nullable String value) { + if (value != null) { + Entry entry = lastKeyed(key, first); + if (entry != null) { + setLine(entry.line(), entry.key(), value); + } else { + lines.add(format(key, value)); + } + return; + } + for (Entry entry = lastKeyed(key, first); entry != null; entry = lastKeyed(key, first)) { + lines.remove(entry.line()); + } + } + + private @Nullable Entry lastKeyed(String key, String first) { + String wantedKey = DefinitionValues.lower(key); + String wanted = bare(first); + for (int i = lines.size() - 1; i >= 0; i--) { + Entry entry = entry(i); + if (entry != null && DefinitionValues.lower(entry.key()).equals(wantedKey) + && bare(entry.value().split(",", 2)[0]).equals(wanted)) { + return entry; + } + } + return null; + } + + /** Model objects can be written with or without their {@code $}. */ + private static String bare(String name) { + String lower = DefinitionValues.lower(name.trim()); + return lower.startsWith("$") ? lower.substring(1) : lower; + } + public void setLine(int line, String key, String value) { lines.set(line, format(key, value)); } diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/PackCatalog.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/PackCatalog.java new file mode 100644 index 00000000..886afbd9 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/PackCatalog.java @@ -0,0 +1,112 @@ +package com.jnhprojects.mcheli.workshop; + +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 com.jnhprojects.mcheli.content.pack.PackProblem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** What the package browser shows: the packs a player has, who made them and what is in them. */ +public final class PackCatalog { + private static final String WEAPONS = "assets/mcheli/weapons/"; + + private PackCatalog() { + } + + /** + * One pack as the browser lists it, with nothing left open. + * + * @param vehicles the vehicle names it holds + * @param weapons the weapon names it holds + * @param size its size as a zip, in bytes + */ + public record Info(PackManifest manifest, Path source, boolean zipped, long size, String hash, + List vehicles, List weapons) { + } + + /** + * @param packs the packs that can be used, by id + * @param problems the ones that cannot, with the reason + */ + public record Listing(List packs, List problems) { + } + + /** Reads every pack in {@code mcheli/packs}. */ + public static Listing read(Path folder) { + PackFolder.Scan scan = PackFolder.scan(folder); + List infos = new ArrayList<>(); + List problems = new ArrayList<>(scan.problems()); + for (ContentPack pack : scan.packs()) { + try (pack) { + infos.add(info(pack)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + return new Listing(List.copyOf(infos), List.copyOf(problems)); + } + + /** Describes an open pack, such as one a server sent. */ + public static Info info(ContentPack pack) throws IOException { + List vehicles = new ArrayList<>(); + List weapons = new ArrayList<>(); + for (String file : pack.files()) { + if (!file.endsWith(".txt")) { + continue; + } + for (WorkshopKind kind : WorkshopKind.values()) { + addName(file, "assets/mcheli/" + kind.folder() + "/", vehicles); + } + addName(file, WEAPONS, weapons); + } + return new Info(pack.manifest(), pack.source(), !Files.isDirectory(pack.source()), pack.archiveSize(), pack.hash(), + List.copyOf(vehicles), List.copyOf(weapons)); + } + + private static void addName(String file, String prefix, List names) { + if (file.startsWith(prefix) && file.indexOf('/', prefix.length()) < 0) { + names.add(file.substring(prefix.length(), file.length() - 4)); + } + } + + /** + * Copies a zip someone gave the player into {@code mcheli/packs}, after checking it the way the + * game will. A pack with the same id is left alone, since the player may have changes in it. + * + * @return the pack's manifest + * @throws WorkshopException when it is not a usable pack or the id is taken + */ + public static PackManifest install(Path zip, Path folder) throws WorkshopException { + PackManifest manifest; + try (ContentPack pack = ContentPack.open(zip)) { + manifest = pack.manifest(); + } catch (PackException e) { + throw new WorkshopException(zip.getFileName() + ": " + e.getMessage()); + } catch (IOException | RuntimeException e) { + throw new WorkshopException("Could not read %s: %s".formatted(zip.getFileName(), e)); + } + for (Info existing : read(folder).packs()) { + if (existing.manifest().id().equals(manifest.id())) { + throw new WorkshopException("You already have a pack called %s (%s). Remove it first to replace it." + .formatted(manifest.id(), existing.source().getFileName())); + } + } + Path target = folder.resolve(manifest.id() + ".zip"); + if (Files.exists(target)) { + throw new WorkshopException("mcheli/packs already holds a file called %s.".formatted(target.getFileName())); + } + try { + Files.createDirectories(folder); + Files.copy(zip, target); + } catch (IOException e) { + throw new WorkshopException("Could not copy the pack: " + e); + } + return manifest; + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java index 916c13aa..1d59f865 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java +++ b/common/src/main/java/com/jnhprojects/mcheli/workshop/WorkshopProject.java @@ -1,11 +1,15 @@ package com.jnhprojects.mcheli.workshop; import com.jnhprojects.mcheli.content.DefinitionReader; +import com.jnhprojects.mcheli.content.DefinitionValues; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; 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 com.jnhprojects.mcheli.model.PartTextures; import org.jspecify.annotations.Nullable; import java.io.ByteArrayOutputStream; @@ -19,8 +23,10 @@ import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.CRC32; @@ -44,6 +50,8 @@ public final class WorkshopProject { 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 static final String PART_TEXTURE = "PartTexture"; + private static final String WEAPON_SOUND = "WeaponSound"; private final Path root; private final WorkshopKind kind; @@ -354,6 +362,148 @@ private static int afterParent(PartType type, List parts, int sele return at; } + // Textures + + /** The objects of the model that can have a texture of their own, by part name. */ + public List texturableParts() { + return PartTextures.names(model.mesh()); + } + + /** The name of the texture a part has of its own, or {@code null} if it wears the vehicle's paint. */ + public @Nullable String partTexture(String part) { + String value = document.keyed(PART_TEXTURE, part); + String[] values = value != null ? value.split(",") : new String[0]; + return values.length == 2 ? DefinitionValues.lower(values[1].trim()) : null; + } + + /** The vehicle's own texture, the one a freshly placed vehicle wears. */ + public String mainTexture() { + return definition.textures().getFirst(); + } + + /** + * Copies a PNG in as a part's texture, or as the vehicle's own with {@code part} {@code null}. The + * file replaces the one in the pack straight away, which is what makes the preview live. The + * definition line is a normal edit: it can be undone, and needs saving. + * + * @return the texture's name and what its author may want to fix + * @throws WorkshopException when the file cannot be used or copied + */ + public ImportedTexture importTexture(@Nullable String part, Path png) throws WorkshopException { + AssetImport.Texture checked = AssetImport.texture(png); + String name = part != null ? vehicle + "_" + fileSafe(part) : mainTexture(); + copyIn(png, assets().resolve("textures").resolve(kind.folder()).resolve(name + ".png")); + if (part != null) { + document.setKeyed(PART_TEXTURE, part, part + ", " + name); + } + return new ImportedTexture(name, checked); + } + + /** A texture now in the pack. */ + public record ImportedTexture(String name, AssetImport.Texture checked) { + } + + /** Puts a part back in the vehicle's paint. The PNG stays in the pack, for undo. */ + public void clearPartTexture(String part) { + document.setKeyed(PART_TEXTURE, part, null); + } + + // Sounds + + /** What a sound belongs to: the engine, the rotor, or one of the vehicle's weapons. */ + public enum SoundKind { + ENGINE, ROTOR, WEAPON + } + + /** @param weapon the weapon's name, for a {@link SoundKind#WEAPON} */ + public record SoundSlot(SoundKind kind, @Nullable String weapon) { + } + + /** Everything on this vehicle that can have a sound. Tanks and cars have no rotor. */ + public List soundSlots() { + List slots = new ArrayList<>(); + slots.add(new SoundSlot(SoundKind.ENGINE, null)); + if (kind == WorkshopKind.HELICOPTER || kind == WorkshopKind.PLANE) { + slots.add(new SoundSlot(SoundKind.ROTOR, null)); + } + Set weapons = new LinkedHashSet<>(); + for (WeaponGroup group : definition.weaponGroups) { + weapons.add(group.weaponType()); + } + for (String weapon : weapons) { + slots.add(new SoundSlot(SoundKind.WEAPON, weapon)); + } + return slots; + } + + /** The name of the sound set for a slot in the definition, or {@code null} if none is. */ + public @Nullable String sound(SoundSlot slot) { + return switch (slot.kind()) { + case ENGINE -> document.value("Sound"); + case ROTOR -> document.value("RotorSound"); + case WEAPON -> { + WeaponSound own = definition.weaponSounds.get(slot.weapon()); + yield own != null ? own.sound() : null; + } + }; + } + + /** + * Copies an OGG in as a slot's sound, replacing the one in the pack straight away, and sets the + * definition line, which is a normal edit. + * + * @return the sound's name + * @throws WorkshopException when the file cannot be used or copied + */ + public ImportedSound importSound(SoundSlot slot, Path ogg) throws WorkshopException { + List warnings = AssetImport.sound(ogg); + String name = vehicle + "_" + switch (slot.kind()) { + case ENGINE -> "engine"; + case ROTOR -> "rotor"; + case WEAPON -> fileSafe(slot.weapon()); + }; + copyIn(ogg, assets().resolve("sounds").resolve(name + ".ogg")); + setSound(slot, name); + return new ImportedSound(name, warnings); + } + + /** A sound now in the pack. */ + public record ImportedSound(String name, List warnings) { + } + + /** Sets or, with {@code null}, clears a slot's sound. A file already in the pack stays there. */ + public void setSound(SoundSlot slot, @Nullable String name) { + switch (slot.kind()) { + case ENGINE -> document.set("Sound", name); + case ROTOR -> document.set("RotorSound", name); + case WEAPON -> { + WeaponSound own = definition.weaponSounds.get(slot.weapon()); + Float pitch = own != null ? own.pitch() : null; + document.setKeyed(WEAPON_SOUND, slot.weapon(), name == null ? null + : slot.weapon() + ", " + name + (pitch != null ? ", " + WorkshopPart.number(pitch) : "")); + } + } + } + + private static String fileSafe(String name) { + return name.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "_"); + } + + /** A crash halfway through leaves the old file. Dropping a file from the pack itself changes nothing. */ + private static void copyIn(Path from, Path to) throws WorkshopException { + try { + Files.createDirectories(to.getParent()); + if (Files.exists(to) && Files.isSameFile(from, to)) { + return; + } + Path temp = to.resolveSibling(to.getFileName() + ".tmp"); + Files.copy(from, temp, StandardCopyOption.REPLACE_EXISTING); + move(temp, to); + } catch (IOException e) { + throw new WorkshopException("Could not copy %s: %s".formatted(from.getFileName(), e)); + } + } + /** Whether the document differs from what is on disk. */ public boolean isDirty() { return !document.snapshot().equals(saved); @@ -380,6 +530,10 @@ private void write() throws IOException { private static void writeAtomically(Path file, String text) throws IOException { Path temp = file.resolveSibling(file.getFileName() + ".tmp"); Files.writeString(temp, text, StandardCharsets.UTF_8); + move(temp, file); + } + + private static void move(Path temp, Path file) throws IOException { try { Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException e) { 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 2c0683fe..9534d5b2 100644 --- a/common/src/main/resources/assets/mcheli/lang/en_us.json +++ b/common/src/main/resources/assets/mcheli/lang/en_us.json @@ -1,7 +1,16 @@ { "commands.mcheli.give": "Gave %s %s to %s players", "commands.mcheli.list": "%s vehicles: %s", + "commands.mcheli.approve.left_out": "%s is in mcheli/packs now, but the server's settings left it out:", + "commands.mcheli.approve.success": "%s from %s is live, and players are getting it", + "commands.mcheli.packs.author": " by %s", "commands.mcheli.packs.entry": "%s %s, %s: %s vehicles, %s weapons, hash %s", + "commands.mcheli.pending.entry": "%s %s, author \"%s\", sent by %s, %s MB: %s", + "commands.mcheli.pending.failed": "Could not do that: %s", + "commands.mcheli.pending.none": "No packs are waiting", + "commands.mcheli.pending.replaces": " It would replace: %s", + "commands.mcheli.pending.unknown": "No pack called %s is waiting", + "commands.mcheli.reject.success": "%s from %s was thrown away", "commands.mcheli.packs.header": "%s content packs from %s:", "commands.mcheli.packs.none": "No content packs in use. Put them in %s", "commands.mcheli.reload.success": "Reloaded %s vehicles and %s weapons from the import and %s content packs", @@ -53,8 +62,53 @@ "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.paint": "Paint", + "gui.mcheli.workshop.tab.sound": "Sound", "gui.mcheli.workshop.tab.tuning": "Tuning", "gui.mcheli.workshop.tab.pack": "Pack", + "gui.mcheli.workshop.home.packs": "Packs", + "gui.mcheli.workshop.packs.title": "Content packs", + "gui.mcheli.workshop.packs.none": "No content packs yet", + "gui.mcheli.workshop.packs.open_folder": "Open folder", + "gui.mcheli.workshop.packs.author": "By %s", + "gui.mcheli.workshop.packs.id": "Pack id: %s", + "gui.mcheli.workshop.packs.size": "%s MB, hash %s", + "gui.mcheli.workshop.packs.source": "From: %s", + "gui.mcheli.workshop.packs.from_server": "this server", + "gui.mcheli.workshop.packs.vehicles": "%s vehicles: %s", + "gui.mcheli.workshop.packs.weapons": "%s weapons: %s", + "gui.mcheli.workshop.packs.problem": "%s cannot be used: %s", + "gui.mcheli.workshop.packs.drop": "Drop a pack's zip on this window to install it.", + "gui.mcheli.workshop.packs.not_zip": "%s is not a zip. Packs are shared as zip files.", + "gui.mcheli.workshop.packs.installed": "Installed %s. It loads at the next start or /mcheli reload.", + "gui.mcheli.workshop.paint.whole": "Whole vehicle", + "gui.mcheli.workshop.paint.current": "Uses %s", + "gui.mcheli.workshop.paint.vehicle_paint": "Wears the vehicle's paint", + "gui.mcheli.workshop.paint.file": "PNG file", + "gui.mcheli.workshop.paint.drop": "Paint in any image editor with the model's UV layout. Drop the PNG on this window, or paste its path.", + "gui.mcheli.workshop.paint.imported": "%s is in the pack. The file changed straight away; Undo only brings back the line that uses it.", + "gui.mcheli.workshop.import": "Import", + "gui.mcheli.workshop.clear": "Clear", + "gui.mcheli.workshop.sound.engine": "Engine", + "gui.mcheli.workshop.sound.rotor": "Rotor", + "gui.mcheli.workshop.sound.current": "Plays %s", + "gui.mcheli.workshop.sound.default": "Plays the sound of its kind", + "gui.mcheli.workshop.sound.none": "No sound", + "gui.mcheli.workshop.sound.weapon_default": "Plays what the weapon's file says", + "gui.mcheli.workshop.sound.file": "OGG Vorbis file", + "gui.mcheli.workshop.sound.play": "Play", + "gui.mcheli.workshop.sound.volume": "Volume", + "gui.mcheli.workshop.sound.pitch": "Pitch", + "gui.mcheli.workshop.sound.curve": "Pitch by throttle", + "gui.mcheli.workshop.sound.curve_add": "Add a pitch curve", + "gui.mcheli.workshop.sound.curve_remove": "Remove the curve", + "gui.mcheli.workshop.sound.drop": "Drop an OGG on this window, or paste its path.", + "gui.mcheli.workshop.sound.imported": "%s is in the pack. Take a test flight to hear it with the throttle.", + "gui.mcheli.workshop.drop_where": "Pick the Paint or Sound tab first, then drop the file on the window.", + "gui.mcheli.workshop.submit": "Send to server", + "gui.mcheli.workshop.submit_hint": "Sends the pack to this server. Its operators decide whether it goes live, and the answer comes in chat.", + "gui.mcheli.workshop.submit_sending": "Sending to the server: %s%%", + "gui.mcheli.workshop.submit_sent": "Sent. Close the editor to see the server's answer in chat.", "gui.mcheli.workshop.undo": "Undo", "gui.mcheli.workshop.redo": "Redo", "gui.mcheli.workshop.save": "Save", @@ -187,6 +241,17 @@ "gui.mcheli.workshop.tuning.ongroundrollfactor": "Terrain roll", "gui.mcheli.workshop.tuning.weightedcenterz": "Balance point", "gui.mcheli.workshop.tuning.weighttype": "Weight", + "item.mcheli.vehicle.from_pack": "From the pack %s", + "item.mcheli.vehicle.from_pack_by": "From the pack %s, by %s", + "message.mcheli.submit.approved": "An operator approved %s. It is live now.", + "message.mcheli.submit.busy": "The server is taking packs one at a time. Try again in half a minute.", + "message.mcheli.submit.failed": "The server did not take your pack: %s", + "message.mcheli.submit.off": "This server does not take packs from players (packs.allowSubmissions in mcheli-server.toml).", + "message.mcheli.submit.rejected": "An operator turned down %s.", + "message.mcheli.submit.to_operators": "%s sent the pack %s %s. Use /mcheli pending to look at it, then /mcheli approve or /mcheli reject.", + "message.mcheli.submit.too_large": "That pack is larger than the %s MB this server allows.", + "message.mcheli.submit.waiting": "The server has %s. It goes live when an operator approves it.", + "message.mcheli.submit.waiting_for_you": "%s packs from players are waiting for you. See /mcheli pending.", "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:", diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/aircraft/PitchCurveTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/aircraft/PitchCurveTest.java new file mode 100644 index 00000000..c9ecd224 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/aircraft/PitchCurveTest.java @@ -0,0 +1,51 @@ +package com.jnhprojects.mcheli.content.aircraft; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PitchCurveTest { + private static final float NEAR = 0.0001F; + + @Test + void followsStraightLinesBetweenItsPoints() { + PitchCurve curve = PitchCurve.parse("0:0.6, 0.5:1, 1:1.4"); + assertEquals(0.6F, curve.at(0.0F), NEAR); + assertEquals(0.8F, curve.at(0.25F), NEAR); + assertEquals(1.0F, curve.at(0.5F), NEAR); + assertEquals(1.2F, curve.at(0.75F), NEAR); + assertEquals(1.4F, curve.at(1.0F), NEAR); + } + + @Test + void staysLevelBeyondItsEnds() { + PitchCurve curve = PitchCurve.parse("0.25:0.8, 0.75:1.2"); + assertEquals(0.8F, curve.at(0.0F), NEAR); + assertEquals(1.2F, curve.at(1.0F), NEAR); + } + + @Test + void keepsPitchToWhatTheSoundEngineCanPlay() { + PitchCurve curve = PitchCurve.parse("0:0.1, 1:9"); + assertEquals(PitchCurve.MIN_PITCH, curve.pitch(0), NEAR); + assertEquals(PitchCurve.MAX_PITCH, curve.pitch(1), NEAR); + } + + @Test + void refusesWhatIsNotACurve() { + assertThrows(IllegalArgumentException.class, () -> PitchCurve.parse("1")); + assertThrows(IllegalArgumentException.class, () -> PitchCurve.parse("0:1")); + assertThrows(IllegalArgumentException.class, () -> PitchCurve.parse("0:1, 1")); + assertThrows(IllegalArgumentException.class, () -> PitchCurve.parse("0:1, nope:1")); + assertThrows(IllegalArgumentException.class, () -> PitchCurve.parse("0.5:1, 0.5:1.2")); + assertThrows(IllegalArgumentException.class, () -> PitchCurve.parse("0:1, 1.5:1.2")); + } + + @Test + void readsBackWhatItWrites() { + PitchCurve curve = PitchCurve.parse("0:0.6, 0.5:1, 1:1.25").withPitch(1, 1.1F); + assertEquals("0:0.6, 0.5:1.1, 1:1.25", curve.text()); + assertEquals(curve.text(), PitchCurve.parse(curve.text()).text()); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/pack/PendingPacksTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/pack/PendingPacksTest.java new file mode 100644 index 00000000..713eb4b9 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/pack/PendingPacksTest.java @@ -0,0 +1,186 @@ +package com.jnhprojects.mcheli.content.pack; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Anyone on a server can send these files, so most of this is about what must not get through. */ +class PendingPacksTest { + private static final UUID ALICE = new UUID(1, 1); + private static final UUID BOB = new UUID(2, 2); + + @TempDir + Path dir; + private Path packs; + private PendingPacks pending; + + @BeforeEach + void setUp() { + packs = dir.resolve("packs"); + pending = new PendingPacks(dir.resolve("pending")); + } + + private static byte[] zip(String id, String... extraFiles) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream out = new ZipOutputStream(bytes)) { + out.putNextEntry(new ZipEntry(PackManifest.FILE)); + out.write(("id = " + id + "\nauthor = Someone\n").getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + out.putNextEntry(new ZipEntry("assets/mcheli/helicopters/" + id + ".txt")); + out.write("DisplayName = Test\n".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + for (String file : extraFiles) { + out.putNextEntry(new ZipEntry(file)); + out.write(1); + out.closeEntry(); + } + } + return bytes.toByteArray(); + } + + @Test + void keepsAPackWithWhoSentIt() throws Exception { + PendingPacks.Submission stored = pending.store(zip("heli"), "Alice", ALICE); + assertEquals("heli", stored.id()); + assertEquals("Someone", stored.manifest().author()); + assertEquals("Alice", stored.submitter()); + assertEquals(List.of("helicopters/heli.txt"), stored.definitions()); + assertEquals(1, pending.list().size()); + assertEquals(stored.hash(), pending.find("heli").hash()); + assertNull(pending.find("other")); + } + + @Test + void namesFilesByHashNotByAnythingThePlayerChose() throws Exception { + PendingPacks.Submission stored = pending.store(zip("heli"), "..\\..\\evil", ALICE); + assertEquals(stored.hash() + ".zip", stored.file().getFileName().toString()); + assertEquals(dir.resolve("pending"), stored.file().getParent()); + } + + @Test + void refusesWhatIsNotAPackAndKeepsNothing() throws Exception { + assertThrows(PackException.class, () -> pending.store(new byte[]{1, 2, 3}, "Alice", ALICE)); + assertThrows(PackException.class, () -> pending.store(zip("heli", "mods/evil.jar"), "Alice", ALICE)); + assertThrows(PackException.class, () -> pending.store(zip("heli", "assets/mcheli/run.class"), "Alice", ALICE)); + assertThrows(PackException.class, () -> pending.store(zip("../evil"), "Alice", ALICE)); + assertTrue(pending.list().isEmpty()); + try (var files = Files.list(dir.resolve("pending"))) { + assertEquals(0, files.count()); + } + } + + @Test + void aPlayerHasOneSubmissionAtATime() throws Exception { + pending.store(zip("first"), "Alice", ALICE); + pending.store(zip("second"), "Alice", ALICE); + assertEquals(1, pending.list().size()); + assertNull(pending.find("first")); + assertNotNull(pending.find("second")); + try (var files = Files.list(dir.resolve("pending"))) { + assertEquals(2, files.count()); + } + } + + @Test + void twoPlayersCannotWaitWithTheSameId() throws Exception { + pending.store(zip("heli"), "Alice", ALICE); + PackException e = assertThrows(PackException.class, () -> pending.store(zip("heli", "README.md"), "Bob", BOB)); + assertTrue(e.getMessage().contains("Alice")); + assertEquals("Alice", pending.find("heli").submitter()); + } + + @Test + void theQueueIsBounded() throws Exception { + for (int i = 0; i < PendingPacks.MAX_PENDING; i++) { + pending.store(zip("pack" + i), "Player" + i, new UUID(9, i)); + } + assertThrows(PackException.class, () -> pending.store(zip("one-more"), "Late", new UUID(10, 0))); + // Someone already waiting can still replace their own. + pending.store(zip("pack0-fixed"), "Player0", new UUID(9, 0)); + assertEquals(PendingPacks.MAX_PENDING, pending.list().size()); + } + + @Test + void rejectingRemovesEverything() throws Exception { + pending.remove(pending.store(zip("heli"), "Alice", ALICE)); + assertTrue(pending.list().isEmpty()); + try (var files = Files.list(dir.resolve("pending"))) { + assertEquals(0, files.count()); + } + } + + @Test + void installingPutsThePackWhereTheGameLoadsIt() throws Exception { + PendingPacks.Submission stored = pending.store(zip("heli"), "Alice", ALICE); + Path installed = pending.install(stored, packs); + assertEquals(packs.resolve("heli.zip"), installed); + assertTrue(pending.list().isEmpty()); + PackFolder.Scan scan = PackFolder.scan(packs); + assertEquals(1, scan.packs().size()); + assertEquals(stored.hash(), scan.packs().getFirst().hash()); + scan.packs().getFirst().close(); + } + + /** Windows will not overwrite a zip the game has open, and the game keeps its packs open. */ + @Test + void anUpdateReplacesAZipThatIsStillOpen() throws Exception { + Files.createDirectories(packs); + Files.write(packs.resolve("heli.zip"), zip("heli")); + try (ContentPack inUse = ContentPack.open(packs.resolve("heli.zip"))) { + PendingPacks.Submission update = pending.store(zip("heli", "README.md"), "Alice", ALICE); + pending.install(update, packs); + + // The open pack can still read its old files. + assertTrue(Files.exists(inUse.assets().resolve("helicopters/heli.txt"))); + PackFolder.Scan scan = PackFolder.scan(packs); + assertEquals(1, scan.packs().size()); + assertEquals(List.of(), scan.problems()); + assertEquals(update.hash(), scan.packs().getFirst().hash()); + scan.packs().getFirst().close(); + } + PendingPacks.cleanReplaced(packs); + try (var files = Files.list(packs)) { + assertEquals(List.of("heli.zip"), files.map(p -> p.getFileName().toString()).toList()); + } + } + + @Test + void aFolderPackIsNeverReplaced() throws Exception { + Path folder = Files.createDirectories(packs.resolve("heli")); + Files.writeString(folder.resolve(PackManifest.FILE), "id = heli\n"); + PendingPacks.Submission update = pending.store(zip("heli"), "Alice", ALICE); + assertThrows(PackException.class, () -> pending.install(update, packs)); + assertTrue(Files.exists(folder.resolve(PackManifest.FILE))); + assertNotNull(pending.find("heli")); + } + + @Test + void anUnrelatedFileWithTheSameNameIsLeftAlone() throws Exception { + Files.createDirectories(packs); + Files.write(packs.resolve("heli.zip"), zip("other")); + Path installed = pending.install(pending.store(zip("heli"), "Alice", ALICE), packs); + assertFalse(installed.equals(packs.resolve("heli.zip"))); + PackFolder.Scan scan = PackFolder.scan(packs); + assertEquals(2, scan.packs().size()); + for (ContentPack pack : scan.packs()) { + pack.close(); + } + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/model/PartTexturesTest.java b/common/src/test/java/com/jnhprojects/mcheli/model/PartTexturesTest.java new file mode 100644 index 00000000..d565e5a4 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/model/PartTexturesTest.java @@ -0,0 +1,54 @@ +package com.jnhprojects.mcheli.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PartTexturesTest { + private static MeshGroup group(String name) { + return new MeshGroup(name, new float[0], new float[0], new float[0]); + } + + @Test + void noOverrideLeavesEverythingInThePaint() { + List groups = List.of(group("$body"), group("trim")); + List buckets = PartTextures.assign(groups, Map.of()); + assertEquals(1, buckets.size()); + assertNull(buckets.getFirst().texture()); + assertEquals(groups, buckets.getFirst().groups()); + } + + @Test + void plainObjectsFollowThePartThatOwnsThem() { + MeshGroup body = group("$body"); + MeshGroup trim = group("trim"); + MeshGroup blade = group("$blade0"); + MeshGroup tip = group("tip"); + List buckets = PartTextures.assign(List.of(body, trim, blade, tip), Map.of("blade0", "my-blade")); + assertEquals(2, buckets.size()); + assertEquals(List.of(body, trim), buckets.get(0).groups()); + assertEquals("my-blade", buckets.get(1).texture()); + assertEquals(List.of(blade, tip), buckets.get(1).groups()); + } + + @Test + void aPlainObjectCanHaveItsOwnTexture() { + MeshGroup body = group("$body"); + MeshGroup cockpit = group("Cockpit"); + MeshGroup trim = group("trim"); + List buckets = PartTextures.assign(List.of(body, cockpit, trim), Map.of("cockpit", "my-cockpit")); + assertEquals(List.of(body, trim), buckets.get(0).groups()); + assertEquals("my-cockpit", buckets.get(1).texture()); + assertEquals(List.of(cockpit), buckets.get(1).groups()); + } + + @Test + void namesLeaveOutWhatAnObjCallsItsFirstGroup() { + Mesh mesh = new Mesh("test", List.of(group("Default"), group("$Body"), group("trim"), group("$blade0"))); + assertEquals(List.of("body", "trim", "blade0"), PartTextures.names(mesh)); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopAssetsTest.java b/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopAssetsTest.java new file mode 100644 index 00000000..bae00fb2 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/workshop/WorkshopAssetsTest.java @@ -0,0 +1,255 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +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.nio.charset.StandardCharsets; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** PNGs and OGGs taken in by the workshop, and the pack browser over what comes out. */ +class WorkshopAssetsTest { + private static final WorkshopProject.SoundSlot ENGINE = new WorkshopProject.SoundSlot(WorkshopProject.SoundKind.ENGINE, null); + private static final WorkshopProject.SoundSlot ROTOR = new WorkshopProject.SoundSlot(WorkshopProject.SoundKind.ROTOR, null); + + @TempDir + Path dir; + private Path packs; + private WorkshopProject project; + + @BeforeEach + void setUp() throws Exception { + packs = Files.createDirectory(dir.resolve("packs")); + Path obj = dir.resolve("heli.obj"); + Files.writeString(obj, """ + o $body + v 0 0 0 + v 1 0 0 + v 0 1 0 + vt 0 0 + vt 1 0 + vt 0 1 + f 1/1 2/2 3/3 + o $cockpit + v 0 0 1 + v 1 0 1 + v 0 1 1 + f 4/1 5/2 6/3 + """); + project = WorkshopProject.create(packs, "test-pack", WorkshopKind.HELICOPTER, "heli", "Someone", obj, null); + } + + private Path png(String name, int width, int height) throws Exception { + Path file = dir.resolve(name); + Files.write(file, pngBytes(width, height)); + return file; + } + + private static byte[] pngBytes(int width, int height) { + byte[] bytes = new byte[33]; + byte[] signature = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + System.arraycopy(signature, 0, bytes, 0, signature.length); + bytes[11] = 13; + bytes[12] = 'I'; + bytes[13] = 'H'; + bytes[14] = 'D'; + bytes[15] = 'R'; + for (int i = 0; i < 4; i++) { + bytes[16 + i] = (byte) (width >>> (24 - i * 8)); + bytes[20 + i] = (byte) (height >>> (24 - i * 8)); + } + return bytes; + } + + private Path ogg(String name, String codec) throws Exception { + Path file = dir.resolve(name); + byte[] page = new byte[64]; + byte[] magic = "OggS".getBytes(StandardCharsets.ISO_8859_1); + System.arraycopy(magic, 0, page, 0, magic.length); + byte[] head = codec.getBytes(StandardCharsets.ISO_8859_1); + System.arraycopy(head, 0, page, 29, head.length); + Files.write(file, page); + return file; + } + + private AircraftDefinition loadSaved() throws Exception { + project.save(); + try (ContentPack pack = ContentPack.open(project.root())) { + ContentRegistry registry = ContentRegistry.load(List.of(pack.contentSource())); + assertEquals(List.of(), registry.problems()); + return registry.any("heli"); + } + } + + @Test + void partsWithTexturesComeBackFromTheModel() { + assertEquals(List.of("body", "cockpit"), project.texturableParts()); + } + + @Test + void aPartTextureIsCopiedInAndLoads() throws Exception { + WorkshopProject.ImportedTexture imported = project.importTexture("cockpit", png("Paint Job.png", 64, 32)); + assertEquals("heli_cockpit", imported.name()); + assertEquals(64, imported.checked().width()); + assertTrue(Files.exists(project.assets().resolve("textures/helicopters/heli_cockpit.png"))); + assertEquals("heli_cockpit", project.partTexture("cockpit")); + + AircraftDefinition def = loadSaved(); + assertEquals("heli_cockpit", def.partTextures.get("cockpit")); + assertEquals("Someone", def.credit().author()); + } + + @Test + void theSamePartNeverGetsTwoLines() throws Exception { + project.importTexture("cockpit", png("a.png", 16, 16)); + project.importTexture("cockpit", png("b.png", 32, 32)); + assertEquals(1, project.document().entries().stream().filter(e -> e.key().equals("PartTexture")).count()); + } + + @Test + void aWholeVehicleTextureReplacesItsFileAndAddsNoLine() throws Exception { + List before = project.document().snapshot(); + project.importTexture(null, png("new.png", 64, 64)); + assertEquals(before, project.document().snapshot()); + assertEquals(64, AssetImport.texture(project.assets().resolve("textures/helicopters/heli.png")).width()); + } + + @Test + void clearingAPartGoesBackToThePaintAndUndoWorks() throws Exception { + project.importTexture("cockpit", png("a.png", 16, 16)); + List imported = project.document().snapshot(); + project.clearPartTexture("cockpit"); + assertNull(project.partTexture("cockpit")); + project.document().restore(imported); + assertEquals("heli_cockpit", project.partTexture("cockpit")); + assertTrue(Files.exists(project.assets().resolve("textures/helicopters/heli_cockpit.png"))); + } + + @Test + void importingAFileFromThePackItselfChangesNothing() throws Exception { + project.importTexture("cockpit", png("a.png", 16, 16)); + Path inPack = project.assets().resolve("textures/helicopters/heli_cockpit.png"); + project.importTexture("cockpit", inPack); + assertEquals(16, AssetImport.texture(inPack).width()); + } + + @Test + void refusesWhatIsNotAPng() throws Exception { + Path fake = dir.resolve("fake.png"); + Files.writeString(fake, "not an image at all, not even close to one"); + assertThrows(WorkshopException.class, () -> project.importTexture("cockpit", fake)); + Path jpeg = dir.resolve("photo.jpg"); + Files.writeString(jpeg, "x"); + assertThrows(WorkshopException.class, () -> project.importTexture("cockpit", jpeg)); + assertThrows(WorkshopException.class, () -> project.importTexture("cockpit", dir.resolve("missing.png"))); + assertThrows(WorkshopException.class, () -> AssetImport.texture(png("huge.png", 100000, 16))); + assertNull(project.partTexture("cockpit")); + } + + @Test + void warnsAboutALargeTexture() throws Exception { + assertFalse(AssetImport.texture(png("large.png", 4096, 4096)).warnings().isEmpty()); + assertTrue(AssetImport.texture(png("fine.png", 1024, 1024)).warnings().isEmpty()); + } + + @Test + void engineAndRotorSoundsLoad() throws Exception { + assertEquals("heli_engine", project.importSound(ENGINE, ogg("Engine Loop.ogg", "\u0001vorbis")).name()); + assertEquals("heli_rotor", project.importSound(ROTOR, ogg("rotor.ogg", "\u0001vorbis")).name()); + assertTrue(Files.exists(project.assets().resolve("sounds/heli_engine.ogg"))); + assertEquals("heli_engine", project.sound(ENGINE)); + + AircraftDefinition def = loadSaved(); + assertEquals("heli_engine", def.sound); + assertEquals("heli_rotor", def.rotorSound); + } + + @Test + void aWeaponSoundKeepsItsPitchWhenReplaced() throws Exception { + project.document().insert(project.document().size(), "AddWeapon", "m230, 0, 0, 0"); + project.refresh(); + WorkshopProject.SoundSlot gun = new WorkshopProject.SoundSlot(WorkshopProject.SoundKind.WEAPON, "m230"); + assertTrue(project.soundSlots().contains(gun)); + + project.document().setKeyed("WeaponSound", "m230", "m230, old, 1.25"); + project.refresh(); + project.importSound(gun, ogg("gun.ogg", "\u0001vorbis")); + project.refresh(); + assertEquals("heli_m230", project.sound(gun)); + assertEquals(1.25F, project.definition().weaponSounds.get("m230").pitch()); + + project.setSound(gun, null); + project.refresh(); + assertNull(project.sound(gun)); + } + + @Test + void tanksHaveNoRotorSlot() throws Exception { + Path obj = project.assets().resolve("models/helicopters/heli.obj"); + WorkshopProject tank = WorkshopProject.create(packs, "tank-pack", WorkshopKind.TANK, "tank", "", obj, null); + assertFalse(tank.soundSlots().contains(ROTOR)); + assertTrue(tank.soundSlots().contains(ENGINE)); + } + + @Test + void refusesWhatTheGameCannotPlay() throws Exception { + WorkshopException opus = assertThrows(WorkshopException.class, () -> project.importSound(ENGINE, ogg("voice.ogg", "OpusHead"))); + assertTrue(opus.getMessage().contains("Opus")); + assertThrows(WorkshopException.class, () -> project.importSound(ENGINE, ogg("other.ogg", "FLAC"))); + Path mp3 = dir.resolve("song.mp3"); + Files.writeString(mp3, "x"); + assertThrows(WorkshopException.class, () -> project.importSound(ENGINE, mp3)); + Path renamed = dir.resolve("renamed.ogg"); + Files.writeString(renamed, "RIFF and so on"); + assertThrows(WorkshopException.class, () -> project.importSound(ENGINE, renamed)); + assertNull(project.sound(ENGINE)); + } + + @Test + void theBrowserListsWhoMadeWhat() throws Exception { + project.save(); + PackCatalog.Listing listing = PackCatalog.read(packs); + assertEquals(List.of(), listing.problems()); + PackCatalog.Info info = listing.packs().getFirst(); + assertEquals("Someone", info.manifest().author()); + assertEquals(List.of("heli"), info.vehicles()); + assertFalse(info.zipped()); + assertTrue(info.size() > 0); + } + + @Test + void theBrowserReportsABrokenPack() throws Exception { + Files.writeString(packs.resolve("broken.zip"), "not a zip"); + PackCatalog.Listing listing = PackCatalog.read(packs); + assertEquals(1, listing.packs().size()); + assertEquals("broken.zip", listing.problems().getFirst().pack()); + } + + @Test + void installingCopiesAZipAndKeepsWhatIsThere() throws Exception { + Path exported = project.export(dir.resolve("out")); + Path other = Files.createDirectory(dir.resolve("other")); + PackManifest manifest = PackCatalog.install(exported, other); + assertEquals("test-pack", manifest.id()); + assertNotNull(PackCatalog.read(other).packs().getFirst()); + assertThrows(WorkshopException.class, () -> PackCatalog.install(exported, other)); + assertThrows(WorkshopException.class, () -> PackCatalog.install(exported, packs)); + + Path notAPack = dir.resolve("photos.zip"); + Files.writeString(notAPack, "nope"); + assertThrows(WorkshopException.class, () -> PackCatalog.install(notAPack, other)); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 530b8ffd..340758b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -714,6 +714,9 @@ The pure half is `workshop` in `common`, tested without Minecraft: - **`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. +- **`AssetImport`** checks a PNG or OGG before it is copied in. Both would otherwise fail silently in + game, an OGG holding Opus most of all, so the codec is read from the file's first page. +- **`PackCatalog`** is what the package browser lists, with nothing left open, and installs a zip. `client.workshop` holds the screens. `WorkshopScreen` hands an `AircraftRenderState` holding the parsed definition to vanilla's `GuiGraphicsExtractor.entity`, which draws through the registered @@ -731,8 +734,64 @@ A test flight (`TestFlightPacket`, `workshop.TestFlight`) saves, reloads the con `/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. +is on that machine, and on a dedicated server it arrives only by operator approval, when it is no longer +a test. `WorkshopClient` keeps the screen across the flight and reopens it when the player is out, or +after five seconds if no vehicle came. + +### Part textures + +`PartTexture` is a map on the definition from part name to texture name. `PartRenderer.submit` takes a +list of `MeshGroup`s and stays on its old single-texture path when the map is empty, so no existing +vehicle pays anything. Otherwise `model.PartTextures.assign` sorts the groups by texture, following the +rule that a `$` object owns the plain objects behind it, and `GlassSplit` caches the result. The +buckets stay the same objects from frame to frame, which its identity-keyed glass cache relies on. + +A `$` object nothing animates would never draw, since it also ends `$body`, so `hull()` draws +`$cockpit` right after the body. No model in the 1.0.3 content has one, so this changes nothing there. + +An import overwrites the file in the pack at once and calls `RuntimeAssets.refreshTexture`, then clears +the mesh cache, because glass is worked out from the texture's alpha. Only the definition line is in the +undo history. + +### Pack sounds + +Loops have always streamed their file through `RuntimeSoundInstance`. One-shots did not: the server's +`level.playSound` ends at a `SimpleSoundInstance` on the client, and the sound manager drops an event it +does not know from a `sounds.json`, which a pack cannot have. So a pack's weapon sound was silent. +`ModSounds.playAt` and `playTo` now check `ContentPacks.hasSound` and send those sounds as a +`PackSoundPacket`, with vanilla's range, which `OneShotSound` plays through the same stream path. +Sounds from the import or a resource pack still go the vanilla way. + +`EngineSoundPlayer` keeps up to two loops per vehicle, engine and rotor. Both take their pitch from the +definition's `PitchCurve` when it has one, at `visibleThrottle`, and from `EngineSoundSource.enginePitch` +when it does not. The curve keeps pitch to 0.5 to 2, the range `SoundEngine` clamps to. `WeaponSound` +sets `WeaponSet.ownSound`, which only `ServerFiring` reads, so weapon files stay shared. + +### Submissions + +Any player can reach the upload packet, so it is written as untrusted input and off by default +(`packs.allowSubmissions`). + +``` +Send to server ── PackSubmitPacket(offset, total, 30 KiB) ──▶ network.PackSubmissions + │ off, too large, busy: refused + PendingPacks.store, off the main thread ◀────┘ out of order: dropped + │ mcheli/pending/.zip + .properties +/mcheli approve ── PendingPacks.install ──▶ mcheli/packs/.zip ── ContentReload ──▶ every player +``` + +- A serverbound custom payload is capped at 32 KiB, a good deal lower than the 128 KiB chunks of a + download, which is why the packet is separate from `PackDataPacket`. +- `total` is checked against `maxPackBytes` on the first chunk and again on each one. The buffer grows + as bytes arrive rather than being allocated from what the client claims. +- `PendingPacks` names files by hash, never from anything the client sent, and opens each with + `ContentPack`, which bounds files and unpacked size and refuses anything but pack content. One + submission per player and `MAX_PENDING` overall bound the disk. The manifest's author is unverified, + so a submission also records the player the server saw. +- A zip in use stays open, and Windows will not overwrite it, so `install` renames the old one to + `*.zip.replaced-`, which the scan ignores, and `cleanReplaced` deletes it on a later load. + `PendingPacksTest` does this over an open pack; a test on Windows is the one that counts. +- With `requireApproval` off a pack goes live on arrival, unless its id is already in use. ## Assets @@ -761,7 +820,7 @@ convert again (1.1.0-beta.1 bumped it to 2 for the generic item recipes). ### Runtime assets Vehicle content can change while the game runs (`/mcheli reload`, content packs arriving from a -server, and the workshop later), and a resource reload is slow and does not exist on a server. +server, and the workshop), and a resource reload is slow and does not exist on a server. The client therefore reads vehicle assets through `client.RuntimeAssets`: the resource manager first, so resource packs keep the last word, then the runtime roots, the `assets/mcheli` folders of the content packs in use. diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 68e9ce43..f2099f8c 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -197,6 +197,9 @@ seats. The pilot's Y key lets them out: | `/mcheli give [count]` | operators | Hands out vehicles, for example `/mcheli give @s ah-64` | | `/mcheli packs` | everyone | The content packs in use, and any problems with them | | `/mcheli reload` | operators | Reads the vehicle and weapon files and the content packs again without a restart, and sends players the new packs | +| `/mcheli pending` | operators | The packs players have sent the server, waiting for a decision ([details](content/packs.md#vehicles-from-players)) | +| `/mcheli approve ` | operators | Puts a waiting pack in use and sends it to every player | +| `/mcheli reject ` | operators | Throws a waiting pack away | Operators can run a match from the **team manager** (M): switch PvP, shuffle every player online into the teams, make a team with a colour and friendly fire on or off, send everyone to their diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6648ff2e..361e0c9b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -138,7 +138,7 @@ Rules that hold for every beta: | 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) | Released | -| 12 | `1.1.0-beta.8` | Workshop: textures, sounds and sharing (Forge 26.3 only) | Planned | +| 12 | `1.1.0-beta.8` | Workshop: textures, sounds and sharing (Forge 26.3 only) | Released | | 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 | @@ -289,11 +289,11 @@ built here syncs to other players with no extra work. ## Phase 12: workshop, textures, sounds and sharing (`1.1.0-beta.8`) -- [ ] Texture painting on the model and PNG import per part (cockpit textures included), with - live preview -- [ ] OGG sounds for engine, rotor and weapons, with pitch curves -- [ ] Package browser, author credits shown in game -- [ ] Operator approval before a player's new vehicle goes live on a server +- [x] PNG import per part (cockpit textures included), with live preview. Painting happens in the + player's own image editor; a brush on the 3D model was dropped from the plan +- [x] OGG sounds for engine, rotor and weapons, with pitch curves for the engine and rotor loops +- [x] Package browser, author credits shown in game +- [x] Operator approval before a player's new vehicle goes live on a server ## Phase 13: every loader on every supported version (`1.1.0-beta.9`) @@ -375,7 +375,6 @@ or recipe, and the editor is a screen of its own once it is open: start). Each part maps to definition keys such as `AddSeat`, `AddRotor`, `AddWing`, `AddWeapon`. - **Flight and drive tuning:** speed, lift, mass, turn rates, fuel and hit points, with a test flight from the editor. -- **Textures:** paint on the model with a brush, or import PNGs per part (a custom cockpit - texture included), with live preview. -- **Sounds:** pick OGG files for engine, rotor and weapons, with pitch curves. +- **Textures:** import PNGs per part (a custom cockpit texture included), with live preview. +- **Sounds:** pick OGG files for engine, rotor and weapons, with pitch curves for the loops. - **Save and export** to a package; **load** any package to keep editing. diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md index c4e47a7f..4787d787 100644 --- a/docs/SCREENSHOTS.md +++ b/docs/SCREENSHOTS.md @@ -3,6 +3,18 @@ Every phase of MCHeli Rework in development builds, newest first. The [README](../README.md) shows the latest phase only. +## Phase 12 (`1.1.0-beta.8`) + +The second half of the workshop: textures per part, sounds with pitch curves and the pack browser. The +checkered colours are test textures, to show which part wears which. + +| | | +|---|---| +| ![The Paint tab: the model's parts listed on the left, the helicopter in the middle with blue rotor blades, a red cockpit panel and a green hull, and the blade texture previewed on the right](images/phase-12/workshop-paint.png) | ![The Sound tab: Engine, Rotor and a weapon listed on the left, and on the right the engine's sound file, Play and Clear buttons, its volume, and a pitch curve with five points](images/phase-12/workshop-sound.png) | +| A PNG per part, showing on the model straight away | An OGG for the engine, with its pitch following the throttle along a curve | +| ![The pack browser: two packs listed on the left, and on the right the selected pack's name, author, id, size, source and vehicles](images/phase-12/workshop-packs.png) | | +| Who made each pack, and what is in it | | + ## Phase 11 (`1.1.0-beta.7`) The vehicle workshop: importing a model, placing its parts, tuning, test flights and saving a pack. diff --git a/docs/TESTING.md b/docs/TESTING.md index 968e5891..5c3e6d88 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -680,6 +680,59 @@ Forge 26.3 only, in single player unless it says otherwise. The guide is - [ ] 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. +## Phase 12 checklist (`1.1.0-beta.8`) + +Forge 26.3 only. These were driven through the real client and a real dedicated server with a test +class, so what is left is what that cannot check: listening, a real mouse and real players. The guide +is [docs/content/workshop.md](content/workshop.md). + +### Paint + +- [ ] On the Paint tab, drop a PNG on Whole vehicle and on a part: the model changes at once, and the + preview on the right shows the picture. +- [ ] A cockpit modelled as `$cockpit` draws with the hull and takes its own texture; so do `$blade0` + and a plain object inside `$body`. Glass in a part's texture still draws as glass. +- [ ] Clear puts the part back in the paint; Undo and Redo bring the line back. Cycling paint schemes in + game leaves a part with its own texture as it is. +- [ ] A `.jpg`, and a text file renamed to `.png`, are refused with a clear message. +- [ ] A vehicle with part textures, seen by another player on a server, looks the same. + +### Sound + +- [ ] Listen: an engine OGG plays and its pitch follows the throttle along the curve you dragged. The + rotor loop plays alongside it at its own volume. Clear goes back to the default. +- [ ] Listen: a vehicle with `WeaponSound` fires with that sound, heard by another player nearby. Other + vehicles with the same weapon are not affected, and neither is a weapon sound from the import. +- [ ] Listen: a loop does not click or stutter while the throttle changes. +- [ ] An `.mp3`, and an OGG holding Opus, are refused with a clear message. + +### Browser and credits + +- [ ] Packs on the workshop's first screen lists your packs with author, version, size and vehicles, + and a broken zip with the reason. On a server, its packs show as from this server. +- [ ] Open folder opens `mcheli/packs`. Dropping a zip installs it; dropping one you have is refused and + changes nothing. +- [ ] A pack vehicle's item shows its pack and author; an imported vehicle's shows nothing extra. + `/mcheli packs` shows the author. + +### Sending to a server + +Two real players on a dedicated server, one of them an operator. + +- [ ] With `allowSubmissions` off, Send to server is refused in chat. Nothing appears in `mcheli/pending`. +- [ ] Turn it on and `/mcheli reload`. A player sends: they are told it waits, the operator is told, and + the vehicle does not exist for anyone yet. An operator joining later is told too. +- [ ] `/mcheli pending` lists it with the real sender, and what it would replace. A non-operator cannot + run any of the three commands. +- [ ] `/mcheli approve`: both players download it and can use the vehicle; it is still there after a + server restart. Send a new version and approve it: both get the update without a restart. +- [ ] `/mcheli reject`: the sender is told and the file is gone. +- [ ] A pack over `maxPackSize` is refused. A second send within half a minute is refused. A pack the + `allowList` leaves out tells the operator so on approval. +- [ ] Another player sending a pack with an id that is already waiting is refused. A pack over a folder + pack cannot be approved, and the folder is untouched. +- [ ] With `requireApproval` off, a new pack goes live at once and a pack with an id in use still waits. + ## 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 6cf42b64..ddb42e85 100644 --- a/docs/content/aircraft.md +++ b/docs/content/aircraft.md @@ -275,6 +275,11 @@ seconds is `MaxFuel / FuelConsumption`, here 1200 seconds. | Key | Meaning | |-----|---------| | `Sound = heli` | Engine sound, here `sounds/heli.ogg`. | +| `SoundPitchCurve = 0:0.7, 0.5:1, 1:1.3` | How the engine's pitch follows the throttle, as `throttle:pitch` points with straight lines between them. Throttle runs 0 to 1 and rises from left to right; pitch is kept to 0.5 to 2, what the game can play. Without it, the pitch follows the throttle the way the kind of vehicle does. | +| `RotorSound = my-rotor` | A second loop alongside the engine. Takes its throttle and range from the same places. | +| `RotorSoundVolume = 1.0`, `RotorSoundPitchCurve = 0:0.7, 1:1.3` | Its volume and pitch curve. | +| `WeaponSound = m230, my-gun, 1.2` | What weapon `m230` sounds like on this vehicle: `sounds/my-gun.ogg`, with an optional pitch of 0.5 to 2. Repeatable, one per weapon. Other vehicles using the weapon are not affected. | +| `PartTexture = blade0, my-blade` | Draws an object of the model, here `$blade0`, with `textures//my-blade.png`, whatever paint the vehicle wears. Plain objects behind a `$` part follow it unless they have their own line. Repeatable. | | `SmoothShading = false` | `false` for flat shading, `true` for smoothed normals. The `SmoothShading` option in `mcheli.cfg` overrides every vehicle. | | `HideEntity = false` | Hide the riding players. | | `EntityWidth = 0.9`, `EntityHeight = 0.9` | Render scale of riding mobs, -100.0 to 100.0. 0.5 halves them. | diff --git a/docs/content/modeling.md b/docs/content/modeling.md index de8e18c4..9bdba067 100644 --- a/docs/content/modeling.md +++ b/docs/content/modeling.md @@ -49,6 +49,7 @@ Numbers count from 0 in the order the parts appear in the definition: the first | Part | Object name | Pivot / notes | |------|-------------|---------------| | Fixed hull, cockpit, seats | `$body` | | +| Cockpit with a texture of its own | `$cockpit` | Optional. Does not move; needs a `$body` as well. | | Helicopter rotor blades | `$blade0`, `$blade1` | One blade only; the game copies it round the hub. Model it pointing along the first blade position. | | Plane propeller hub | `$rotor0` | Spins about the axis given in `AddPartRotor`. | | Plane propeller blades | `$blade0` for every blade of `$rotor0` (`AddBlade`) | | @@ -78,8 +79,11 @@ definition or click in the workshop. `AddTexture` and players cycle through them in game. * Glass: give the canopy area a partly transparent alpha in the PNG. Pixels with alpha are drawn as glass, blended with what is behind them; fully opaque pixels are drawn solid. -* A cockpit texture is just part of the same PNG: UV map the instrument panel faces onto their own +* A cockpit texture can be part of the same PNG: UV map the instrument panel faces onto their own area of the texture and paint the gauges there. +* A part can also have a PNG of its own, which keeps its detail when you repaint the rest + (`PartTexture`, or the workshop's Paint tab). For the cockpit, model it as a separate object named + `$cockpit`: it does not move and draws along with `$body`. * Panels that should look different from each side need two faces back to back, each facing out of its own side. diff --git a/docs/content/packs.md b/docs/content/packs.md index dadc3fb7..e9b94608 100644 --- a/docs/content/packs.md +++ b/docs/content/packs.md @@ -75,8 +75,9 @@ imported 1.0.3 content. Raise `version` whenever you share a new build; players parts as `$` objects. * **Textures** are PNG files with the same UV layout as the model. Partly transparent pixels are drawn as glass. -* **Sounds** are OGG files. A vehicle's engine plays the sound its `Sound` key names, so - `Sound = my-heli` plays `sounds/my-heli.ogg`. +* **Sounds** are Ogg Vorbis files. A vehicle's engine plays the sound its `Sound` key names, so + `Sound = my-heli` plays `sounds/my-heli.ogg`. `RotorSound`, `WeaponSound` and a weapon's own `Sound` + name theirs the same way. A vehicle can use weapons from its own pack, from another pack or from the imported content. HUD scripts, crafting recipes and item icons are not part of packs yet: name an imported HUD in @@ -127,6 +128,8 @@ Server settings, in `serverconfig/mcheli-server.toml` of the world, section `[pa | `allowList` | `[]` | Pack ids the server loads. Empty loads every pack in the folder. | | `maxPackSize` | `8` | Largest pack the server loads and sends, in MB. | | `maxTotalSize` | `64` | Most the server loads and sends in all, in MB. Packs are taken in id order until the next one would go over. | +| `allowSubmissions` | `false` | Whether players may send the server packs they made. See [below](#vehicles-from-players). | +| `requireApproval` | `true` | Whether a pack a player sent waits for an operator. | A pack left out by these settings is reported like any other problem. Changes apply when the world starts or after `/mcheli reload`, which also sends every player the new list. @@ -140,6 +143,30 @@ Client setting, in `config/mcheli-client.toml`, section `[packs]`: Downloads run at about 5 MB a second per player alongside the game. A pack that arrives damaged or does not match the server's hash is thrown away and reported in the chat. +### Vehicles from players + +With `allowSubmissions` on, a player can press **Send to server** in the [workshop](workshop.md). The +pack goes to `/mcheli/pending`, online operators are told, and nothing changes for anyone +until an operator decides: + +| Command | Does | +|---------|------| +| `/mcheli pending` | What is waiting: id, version, the author it claims, the player who really sent it, its size and its definition files. It also says what going live would replace. | +| `/mcheli approve ` | Moves it to `mcheli/packs` and reloads, so every player gets it. A pack with the same id is replaced, unless that pack is a folder, which has to be removed by hand first. | +| `/mcheli reject ` | Throws it away. The player who sent it is told, if they are online. | + +A pack is data only and goes through the same checks as any other, but it can still replace any vehicle +or weapon on the server by using its file name, so look at what `/mcheli pending` says before approving. +The server's `allowList` and size limits still apply: a pack they leave out stays in `mcheli/packs` +unused, and the operator is told why. + +A pack larger than `maxPackSize` is refused as it arrives. A player has one pack waiting at a time and +can send again after half a minute, and 16 can wait in all. + +With `requireApproval` off, a pack goes live as soon as it arrives. That lets any player add or replace +vehicles and weapons for everyone, so only turn it off among people you trust. A pack using the id of +one already in use always waits for an operator. + ## Sharing a pack Zip the pack folder and share the zip. Players can drop it into `mcheli/packs`, or simply join a diff --git a/docs/content/workshop.md b/docs/content/workshop.md index 7109c70d..cb08b74c 100644 --- a/docs/content/workshop.md +++ b/docs/content/workshop.md @@ -7,10 +7,6 @@ pack does: other players can install it, and a server sends it to everyone who j ![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 @@ -99,6 +95,52 @@ are listed. A dim name means the value is the default for the kind; emptying a b 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. +## Paint + +![The Paint tab: the model's parts listed on the left, the helicopter in the middle with blue rotor blades, a red cockpit panel and a green hull, and the blade texture previewed on the right](../images/phase-12/workshop-paint.png) + +Paint in any image editor, using the model's UV layout, and bring the result in as a PNG. The **Paint** +tab lists **Whole vehicle** and then every object in the model. Pick one, then drop a PNG on the window +or paste its path and press **Import**. The model changes straight away. + +- **Whole vehicle** replaces the vehicle's own texture. +- A part gets a texture of its own, kept as `_.png`, and a `PartTexture` line in the + definition. It wears that texture with every paint scheme. **Clear** puts it back in the vehicle's + paint. A plain object behind a `$` part follows that part unless it has its own. +- For a **cockpit with its own texture**, model it as an object called `$cockpit`. It stays still and + draws with the hull. See the [modeling guide](modeling.md#textures). + +Partly transparent pixels draw as glass, as in any texture. Extra paint schemes are still added by +hand with `AddTexture`. + +A PNG goes into the pack as soon as you import it. Undo brings back the definition line that uses it, +but not an earlier picture, and closing without saving leaves the file in the pack. Keep your own copy +of anything you may want back. To get rid of a texture you no longer use, delete its file from the pack +folder, since it still counts towards the pack's size. + +## Sound + +![The Sound tab: Engine, Rotor and a weapon listed on the left, and on the right the engine's sound file, Play and Clear buttons, its volume, and a pitch curve with five points](../images/phase-12/workshop-sound.png) + +The **Sound** tab lists the engine, the rotor on helicopters and planes, and each weapon the vehicle +has. Pick one, then drop an OGG on the window or paste its path. It has to be Ogg Vorbis, the only +kind the game can play; an OGG holding Opus or anything else is refused. **Play** plays it once and +**Clear** goes back to what the vehicle would play without it. + +| In the list | Definition key | Notes | +|-------------|----------------|-------| +| Engine | `Sound`, `SoundVolume`, `SoundPitchCurve` | Without a sound of its own, the vehicle plays the default of its kind. | +| Rotor | `RotorSound`, `RotorSoundVolume`, `RotorSoundPitchCurve` | A second loop alongside the engine. | +| A weapon | `WeaponSound` | This vehicle only; the weapon's file and other vehicles using it stay as they are. It can have its own pitch. | + +**Add a pitch curve** on the engine or rotor lets you decide how the loop's pitch follows the throttle. +The left of the graph is throttle at nothing, the right is full throttle, and the faint line across is +normal pitch. Drag a point up or down. Without a curve, the pitch follows the throttle the way that kind +of vehicle always has. The game plays pitch between 0.5 and 2, and that is what the graph covers. + +Keep loops short and seamless, and export them in mono: only mono sounds fade with distance. A test +flight is the way to hear a curve, since **Play** only plays the sound at full throttle. + ## Test flight **Test flight** saves the vehicle, loads it and puts you in its pilot seat where you stand. Get out @@ -107,7 +149,8 @@ where you started and the vehicle disappears. A test vehicle takes no damage and 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. +because the server does not have your pack. [Send it to the server](#sending-a-vehicle-to-a-server) +instead, or have the server's owner install it. ## Saving and sharing @@ -120,3 +163,25 @@ 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. + +## The pack browser + +![The pack browser: two packs listed on the left, and on the right the selected pack's name, author, id, size, source and vehicles](../images/phase-12/workshop-packs.png) + +**Packs** on the workshop's first screen lists the packs in `mcheli/packs` and, on a server, the ones it +sent you: who made each one, its version, size and what is in it. A pack that cannot be used is listed +with the reason. Drop a zip someone gave you on the window to install it; it loads at the next start or +`/mcheli reload`. A pack you already have is never replaced this way. **Open folder** opens +`mcheli/packs`, which is where to remove a pack. + +The author also shows on the vehicle's item and in `/mcheli packs`. It is whatever the pack's manifest +says, so it is a credit and not a proof. + +## Sending a vehicle to a server + +On a server, the **Pack** tab has **Send to server**, which saves the pack and sends it. The answer +comes in chat. If the server takes packs from players, yours waits until an operator approves it, and +then every player gets it. You have one pack waiting at a time, so sending again replaces the last one. +Raise the version when you send a new build of a pack that is already live. + +This is off unless the server's owner turns it on. See [Servers](packs.md#vehicles-from-players). diff --git a/docs/images/phase-12/workshop-packs.png b/docs/images/phase-12/workshop-packs.png new file mode 100644 index 00000000..a823a5d0 Binary files /dev/null and b/docs/images/phase-12/workshop-packs.png differ diff --git a/docs/images/phase-12/workshop-paint.png b/docs/images/phase-12/workshop-paint.png new file mode 100644 index 00000000..7e6277ed Binary files /dev/null and b/docs/images/phase-12/workshop-paint.png differ diff --git a/docs/images/phase-12/workshop-sound.png b/docs/images/phase-12/workshop-sound.png new file mode 100644 index 00000000..853649ec Binary files /dev/null and b/docs/images/phase-12/workshop-sound.png differ diff --git a/gradle.properties b/gradle.properties index c4ef6520..9b7d5dc8 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.7 +mod_version=1.1.0-beta.8 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/ContentPacks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java index c378489c..d3bfdee5 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -5,6 +5,7 @@ import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.content.pack.PackFolder; import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.content.pack.PendingPacks; import net.minecraft.network.chat.Component; import org.jspecify.annotations.Nullable; @@ -12,7 +13,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.function.Consumer; /** @@ -30,15 +33,19 @@ */ public final class ContentPacks { private static final int PROBLEMS_IN_CHAT = 8; + private static final String SOUNDS = "assets/mcheli/sounds/"; private final Path folder; + private final PendingPacks pending; private final LegacyContent legacy; private volatile List active = List.of(); private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); private List retired = List.of(); ContentPacks(Path gameDir, LegacyContent legacy) { this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); this.legacy = legacy; } @@ -106,13 +113,42 @@ public synchronized ContentRegistry load(boolean serverRules) { found.addAll(registry.problems()); close(retired); + PendingPacks.cleanReplaced(folder); retired = active; active = List.copyOf(accepted); problems = List.copyOf(found); + sounds = soundsOf(accepted); MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); return registry; } + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + private static @Nullable String refusal(ContentPack pack, long size, long total) { if (!ServerConfig.isPackAllowed(pack.id())) { return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java index 78a36123..f136ecfc 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -1,15 +1,21 @@ package com.jnhprojects.mcheli; import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; 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.PendingPacks; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; @@ -18,8 +24,11 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Prediction; +import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; /** @@ -29,15 +38,19 @@ * /mcheli content source and counts * /mcheli list [helicopter|plane|...] loaded vehicles * /mcheli packs content packs in use, and their problems -|plane|...] loaded vehicles * /mcheli give <players> <vehicle> [count] (operators) * /mcheli reload re-read the definitions and packs (operators) + * /mcheli pending packs players have sent, waiting (operators) + * /mcheli approve <pack> put a waiting pack in use (operators) + * /mcheli reject <pack> throw a waiting pack away (operators) * */ public final class McheliCommand { private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); private McheliCommand() { } @@ -63,7 +76,118 @@ public static void register(CommandDispatcher dispatcher) { .executes(ctx -> give(ctx, IntegerArgumentType.getInteger(ctx, "count"))))))) .then(Commands.literal("reload") .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) - .executes(McheliCommand::reload))); + .executes(McheliCommand::reload)) + .then(Commands.literal("pending") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } } private static int status(CommandContext ctx) { @@ -93,6 +217,9 @@ private static int packs(CommandContext ctx) { long weapons = content.weapons().stream().filter(def -> def.pack().equals(pack.id())).count(); source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.entry", pack.id(), pack.manifest().version(), pack.manifest().name(), vehicles, weapons, ContentPack.shortHash(pack.hash())), false); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } } MCHeli.packs().reportProblems(source::sendFailure); return packs.size(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java index 48b81523..ed6eeb7e 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -1,8 +1,11 @@ package com.jnhprojects.mcheli; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; import net.minecraft.core.Holder; import net.minecraft.network.protocol.game.ClientboundSoundPacket; import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundSource; @@ -18,8 +21,14 @@ *

The events are not put in the registry. Content files name their sounds freely and a content * pack may bring its own, so they are created on demand as direct holders, which the sound packet * and the sound manager both accept. + * + *

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. */ public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + private static final Map EVENTS = new ConcurrentHashMap<>(); private ModSounds() { @@ -31,17 +40,25 @@ public static SoundEvent get(String name) { /** Plays {@code name} at the entity for everyone nearby. Call on the server. */ public static void playAt(Entity entity, String name, float volume, float pitch) { - entity.level().playSound(null, entity.getX(), entity.getY(), entity.getZ(), get(name), SoundSource.NEUTRAL, volume, pitch); + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); } /** Plays {@code name} at a position for everyone nearby. Call on the server. */ public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { - level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } } /** Plays {@code name} for one player only, such as a hit confirmation. */ public static void playTo(ServerPlayer player, String name, float volume, float pitch) { - player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, - player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java index 6e9fa869..9db31738 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -28,6 +28,8 @@ public final class ServerConfig { private static final ConfigSpec.StringListValue PACK_ALLOW_LIST; private static final ConfigSpec.IntValue MAX_PACK_SIZE; private static final ConfigSpec.IntValue MAX_TOTAL_SIZE; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; static { ConfigSpec.Builder builder = ConfigSpec.builder(); @@ -74,6 +76,15 @@ public final class ServerConfig { .comment("Most the server loads and sends in all, in megabytes. Packs are taken in id order until", "the next one would go over.") .defineInRange("maxTotalSize", 64, 1, 4096); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); builder.pop(); SPEC = builder.build(); @@ -139,6 +150,14 @@ public static long maxTotalPackBytes() { return (long) (SPEC.isLoaded() ? MAX_TOTAL_SIZE.get() : MAX_TOTAL_SIZE.getDefault()) << 20; } + public static boolean allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + public static boolean isDisabled(String vehicle) { if (!SPEC.isLoaded()) { return false; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java index 13f2bad9..d933e518 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.ClientConfig; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.pack.ContentPack; import com.jnhprojects.mcheli.content.pack.ContentSource; @@ -11,6 +12,7 @@ import com.jnhprojects.mcheli.network.PackDataPacket; import com.jnhprojects.mcheli.network.PackListPacket; import com.jnhprojects.mcheli.network.PackRequestPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; import com.jnhprojects.mcheli.platform.Platform; @@ -47,8 +49,8 @@ *

The cache is capped by {@code packs.cacheSize} in the client config: after a download, the * packs used longest ago go first. The current server's packs are always kept. */ -final class ClientPacks implements ClientAccess.PackReceiver { - static final ClientPacks INSTANCE = new ClientPacks(); +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); @@ -123,6 +125,17 @@ public void onPackData(PackDataPacket packet) { } } + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + /** Leaving a server: its packs are no longer needed. */ void loggedOut() { downloads.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 7a3cdc90..7f7afd84 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 @@ -100,6 +100,11 @@ public static void refreshTextures() { } } + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(Identifier id) { + TEXTURES.put(id, upload(id)); + } + private static boolean upload(Identifier id) { try (InputStream in = open(id)) { if (in == null) { diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java index c67df026..d2a46857 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; import com.mojang.blaze3d.platform.NativeImage; import net.minecraft.resources.Identifier; import org.slf4j.Logger; @@ -45,6 +46,7 @@ int at(float u, float v) { private final Map> textures = new HashMap<>(); private final Map, Map> splits = new IdentityHashMap<>(); + private final Map, Map, List>> buckets = new IdentityHashMap<>(); private GlassSplit() { } @@ -57,9 +59,20 @@ Split split(List groups, Identifier texture) { return splits.computeIfAbsent(groups, g -> new HashMap<>()).computeIfAbsent(texture, t -> compute(groups, t)); } + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + void clear() { textures.clear(); splits.clear(); + buckets.clear(); } private Split compute(List groups, Identifier texture) { 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 90f739dd..c76ce659 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 @@ -1,5 +1,7 @@ package com.jnhprojects.mcheli.client.render; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; @@ -22,6 +24,7 @@ import com.jnhprojects.mcheli.content.aircraft.part.WingPart; import com.jnhprojects.mcheli.model.Mesh; import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.math.Axis; import net.minecraft.client.renderer.SubmitNodeCollector; @@ -83,19 +86,19 @@ void submitAll() { nozzles(plane); wings(plane); propellers(plane); - submit(mesh.body()); + hull(); } case HelicopterDefinition heli -> { - submit(mesh.body()); + hull(); rotors(heli); } case VehicleDefinition vehicle -> { - submit(mesh.body()); + hull(); for (VehiclePart part : vehicle.parts) { vehiclePart(vehicle, part); } } - default -> submit(mesh.body()); + default -> hull(); } weapons(); rotatingParts(); @@ -111,6 +114,18 @@ void submitAll() { movingParts(def.canopies, state.canopy); } + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + // Kind specific parts private void rotors(HelicopterDefinition heli) { @@ -483,6 +498,22 @@ private void submit(List groups) { if (groups.isEmpty()) { return; } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + Identifier own = RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderTypes.entityCutoutCull(own), RenderTypes.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, Identifier texture, RenderType opaqueType, RenderType glassType) { GlassSplit.Split split = GlassSplit.INSTANCE.split(groups, texture); if (!split.opaque().isEmpty()) { collector.submitCustomGeometry(pose, opaqueType, (p, buffer) -> MeshRenderer.emit(split.opaque(), p, buffer, light, overlay)); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java index 8f91e640..b32040f5 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.ModSounds; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.entity.EngineSoundSource; import net.minecraft.client.Minecraft; @@ -10,25 +11,29 @@ import net.minecraft.util.RandomSource; /** - * The looping engine sound of one vehicle. Ported from {@code MCH_SoundUpdater}. + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. * - *

Volume and pitch follow the vehicle's throttle every tick. For listeners outside the vehicle - * the sound is placed between them and the vehicle, so that the 16 block range of a normal sound - * stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the vehicle - * approaches or leaves, which is what the original did. + *

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. */ final class EngineSound extends RuntimeSoundInstance { private static final float MAX_DOPPLER = 0.2F; private final AircraftEntity vehicle; private final EngineSoundSource source; + private final boolean rotor; private float doppler; private double lastDistance = -1.0; - EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound) { + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); this.vehicle = vehicle; this.source = source; + this.rotor = rotor; this.looping = true; this.delay = 0; update(); @@ -53,8 +58,9 @@ private void update() { volume = 0.0F; return; } - volume = def.soundVolume * source.engineVolume(); - float pitch = def.soundPitch * source.enginePitch(); + volume = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); if (aboard) { x = vehicle.getX(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java index b0d46e30..62e52284 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -12,15 +12,16 @@ import java.util.Map; /** - * Starts engine loops for vehicles near the player and restarts them after they fall silent. - * The sounds themselves track their vehicle until it stops or disappears. + * Starts engine and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. */ public final class EngineSoundPlayer { /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ private static final int RESTART_DELAY = 20; - private static final Map PLAYING = new HashMap<>(); - private static final Map COOLDOWN = new HashMap<>(); + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); private EngineSoundPlayer() { } @@ -44,22 +45,31 @@ public static void tick() { if (!(entity instanceof AircraftEntity vehicle) || !(entity instanceof EngineSoundSource source)) { continue; } - int id = vehicle.getId(); AircraftDefinition def = vehicle.definition(); - if (def == null || PLAYING.containsKey(id) || COOLDOWN.containsKey(id)) { + if (def == null) { continue; } - String sound = def.soundOrDefault(); double range = def.soundRange * 1.2; - if (sound.isEmpty() || vehicle.distanceToSqr(player) > range * range) { + if (vehicle.distanceToSqr(player) > range * range) { continue; } - if (def.soundVolume * source.engineVolume() > 0.01F) { - EngineSound loop = new EngineSound(vehicle, source, sound); - mc.getSoundManager().play(loop); - PLAYING.put(id, loop); - COOLDOWN.put(id, RESTART_DELAY); - } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); } } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} 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 index 64d9a579..a239b5fc 100644 --- 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 @@ -2,21 +2,29 @@ import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; import com.jnhprojects.mcheli.network.TestFlightPacket; import net.minecraft.client.Minecraft; import org.jspecify.annotations.Nullable; +import java.util.Arrays; + /** - * 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. + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, 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; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; private static @Nullable WorkshopScreen waiting; private static boolean airborne; private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; private WorkshopClient() { } @@ -29,7 +37,20 @@ static void startTestFlight(WorkshopScreen screen, String vehicle) { ModNetwork.sendToServer(new TestFlightPacket(vehicle)); } + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + public static void tick() { + tickSubmit(); WorkshopScreen screen = waiting; Minecraft mc = Minecraft.getInstance(); if (screen == null || mc.player == null) { @@ -47,7 +68,23 @@ public static void tick() { } } + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + public static void loggedOut() { + submitting = null; WorkshopScreen screen = waiting; waiting = null; if (screen != null) { 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 index c036d315..4fec9f18 100644 --- 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 @@ -94,8 +94,10 @@ private void form() { 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.mcheli.workshop.home.packs"), b -> minecraft.gui.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) - .bounds(width / 2 - 50, height - 26, 100, 20).build()); + .bounds(width / 2 + 4, height - 26, 100, 20).build()); } private EditBox box(int x, int y, Component name, String value, int maxLength) { diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..555c5f2b --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.mojang.blaze3d.Blaze3D; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Blaze3D.openPath(folder); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.gui.setScreen(new WorkshopHomeScreen()); + } + + @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); + if (rows.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphicsExtractor graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphicsExtractor graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.text(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @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 index 08bcfe9e..fbe83884 100644 --- 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 @@ -4,12 +4,16 @@ import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.client.render.AircraftRenderState; import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; 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.AssetImport; import com.jnhprojects.mcheli.workshop.EditHistory; import com.jnhprojects.mcheli.workshop.PartField; import com.jnhprojects.mcheli.workshop.PartType; @@ -27,12 +31,17 @@ import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; import net.minecraft.util.FormattedCharSequence; import org.joml.Matrix3x2fStack; import org.joml.Vector3f; import org.jspecify.annotations.Nullable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.ArrayList; import java.util.EnumMap; @@ -40,6 +49,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.TreeSet; import java.util.function.Consumer; @@ -86,12 +96,17 @@ public class WorkshopScreen extends Screen { private static final int GRID_STEP = 2; private static final double ZOOM_STEP = 1.15; + private static final int PREVIEW_SIZE = 64; + private static final int PLOT_HEIGHT = 56; + private static final float PITCH_SNAP = 20.0F; + private static final String DEFAULT_CURVE = "0:0.7, 0.25:0.85, 0.5:1, 0.75:1.15, 1:1.3"; + private enum Tab { - PARTS, TUNING, PACK + PARTS, PAINT, SOUND, TUNING, PACK } private enum Drag { - NONE, ORBIT, PAN, PLANE, AXIS_X, AXIS_Y, AXIS_Z + NONE, ORBIT, PAN, PLANE, AXIS_X, AXIS_Y, AXIS_Z, CURVE } private record Label(Component text, int x, int y, int color) { @@ -136,6 +151,25 @@ private record MergeKey(int line, String field, int component) { private @Nullable Button redoButton; private @Nullable AircraftRenderState renderState; + /** The part the Paint tab is on, {@code null} for the vehicle as a whole. */ + private @Nullable String paintPart; + private WorkshopProject.SoundSlot soundSlot = new WorkshopProject.SoundSlot(WorkshopProject.SoundKind.ENGINE, null); + /** Survives the rebuild an import or a scroll causes. */ + private String assetPath = ""; + private @Nullable Identifier previewTexture; + private int previewX; + private int previewY; + private int previewWidth; + private int previewHeight; + private int previewTextureWidth; + private int previewTextureHeight; + /** Where the curve is drawn, or a height of 0 when none shows. */ + private int plotX; + private int plotY; + private int plotWidth; + private int plotHeight; + private int curvePoint; + public WorkshopScreen(WorkshopProject project) { super(Component.translatable("gui.mcheli.workshop.title")); this.project = project; @@ -202,6 +236,8 @@ protected void init() { labels.clear(); wrapped.clear(); boxes.clear(); + previewTexture = null; + plotHeight = 0; if (!framed) { camera.frame(project.model().min(), project.model().max(), viewRight() - viewLeft(), viewBottom() - viewTop()); framed = true; @@ -212,6 +248,14 @@ protected void init() { partsList(); partPanel(); } + case PAINT -> { + paintList(); + paintPanel(); + } + case SOUND -> { + soundList(); + soundPanel(); + } case TUNING -> tuningPanel(); case PACK -> packPanel(); } @@ -224,6 +268,7 @@ private void topBar() { buttons.add(Button.builder(Component.translatable("gui.mcheli.workshop.tab." + each.name().toLowerCase(Locale.ROOT)), b -> { tab = each; panelScroll = 0; + listScroll = 0; rebuildWidgets(); })); } @@ -649,6 +694,376 @@ private void packPanel() { packRow(rows, "gui.mcheli.workshop.pack.description", m.description(), text -> manifest(new PackManifest(m.id(), project.manifest().name(), project.manifest().author(), project.manifest().version(), text))); rows.label(Component.translatable("gui.mcheli.workshop.pack.id", m.id()), DIM); rows.label(Component.translatable("gui.mcheli.workshop.pack.vehicle", project.vehicle()), DIM); + // Whoever hosts the game has the pack already. A guest or a dedicated server's player does not. + if (!minecraft.hasSingleplayerServer() && rows.shows(FIELD_ROW)) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.submit"), b -> submit()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.workshop.submit_hint"))) + .bounds(rows.x, rows.y, rows.w, FIELD_ROW - 2).build()); + rows.next(); + } + } + + private void submit() { + try { + Path zip = project.export(Platform.get().gameDir().resolve(MCHeli.MOD_ID).resolve("workshop")); + manifestChanged = false; + history.endMerge(); + WorkshopClient.submit(Files.readAllBytes(zip)); + // The editor covers the chat, where the server's answer will come. + say(Component.translatable("gui.mcheli.workshop.submit_sent"), DIM); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), INVALID); + } catch (IOException e) { + say(Component.literal(e.toString()), INVALID); + } + } + + // The Paint tab + + private void paintList() { + List parts = project.texturableParts(); + int rows = listRows(); + listScroll = Math.clamp(listScroll, 0, Math.max(parts.size() + 1 - rows, 0)); + for (int i = listScroll; i <= parts.size() && i < listScroll + rows; i++) { + String part = i == 0 ? null : parts.get(i - 1); + Component name = part == null ? Component.translatable("gui.mcheli.workshop.paint.whole") : Component.literal(part); + Button row = addRenderableWidget(Button.builder(name, b -> { + paintPart = part; + panelScroll = 0; + rebuildWidgets(); + }).bounds(2, TOP + 2 + (i - listScroll) * LIST_ROW, LEFT - 4, LIST_ROW).build()); + row.active = !Objects.equals(part, paintPart); + } + } + + private void paintPanel() { + if (paintPart != null && !project.texturableParts().contains(paintPart)) { + paintPart = null; + } + Rows rows = new Rows(); + String own = paintPart != null ? project.partTexture(paintPart) : project.mainTexture(); + rows.label(paintPart != null ? Component.literal(paintPart) : Component.translatable("gui.mcheli.workshop.paint.whole"), TEXT); + rows.label(trimmed(own != null ? Component.translatable("gui.mcheli.workshop.paint.current", own + ".png") + : Component.translatable("gui.mcheli.workshop.paint.vehicle_paint"), rows.w), DIM); + preview(rows, own != null ? own : project.mainTexture()); + assetRow(rows, "gui.mcheli.workshop.paint.file", path -> importTexture(path)); + if (paintPart != null && own != null && rows.shows(FIELD_ROW)) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.clear"), b -> { + history.record(project.document().snapshot(), null); + project.clearPartTexture(paintPart); + changed(); + rebuildWidgets(); + }).bounds(rows.x, rows.y, rows.w, FIELD_ROW - 2).build()); + rows.next(); + } + hint(rows, "gui.mcheli.workshop.paint.drop"); + } + + /** Reserves room for the texture itself, fitted into a square, and remembers it for drawing. */ + private void preview(Rows rows, String texture) { + Path file = project.assets().resolve("textures").resolve(project.kind().folder()).resolve(texture + ".png"); + if (rows.shows(PREVIEW_SIZE) && Files.isRegularFile(file)) { + try { + AssetImport.Texture image = AssetImport.texture(file); + float scale = (float) PREVIEW_SIZE / Math.max(image.width(), image.height()); + previewTexture = RuntimeAssets.texture(textureId(texture)); + previewX = rows.x; + previewY = rows.y; + previewWidth = Math.max(Math.round(image.width() * scale), 1); + previewHeight = Math.max(Math.round(image.height() * scale), 1); + previewTextureWidth = image.width(); + previewTextureHeight = image.height(); + } catch (WorkshopException e) { + labels.add(new Label(trimmed(Component.literal(e.getMessage()), rows.w), rows.x, rows.y, INVALID)); + } + } + rows.y += PREVIEW_SIZE + 2; + panelEnd = rows.y; + } + + private Identifier textureId(String name) { + return Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + project.kind().folder() + "/" + name + ".png"); + } + + /** A box for a file's path with an Import button, for when dropping a file is not an option. */ + private void assetRow(Rows rows, String name, Consumer onImport) { + rows.label(Component.translatable(name), DIM); + if (rows.shows(FIELD_ROW)) { + int buttonWidth = 40; + textBox(rows.x, rows.y, rows.w - buttonWidth - 2, assetPath, text -> assetPath = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.import"), b -> { + try { + onImport.accept(Path.of(assetPath.trim().replace("\"", ""))); + } catch (InvalidPathException e) { + say(Component.translatable("gui.mcheli.workshop.new.bad_path", assetPath), INVALID); + } + }).bounds(rows.x + rows.w - buttonWidth, rows.y, buttonWidth, FIELD_ROW - 2).build()); + } + rows.next(); + } + + private void hint(Rows rows, String key) { + for (FormattedCharSequence line : font.split(Component.translatable(key), rows.w)) { + if (rows.shows(LABEL_ROW)) { + wrapped.add(new WrappedLine(line, rows.x, rows.y)); + } + rows.y += LABEL_ROW; + panelEnd = rows.y; + } + } + + private void importTexture(Path png) { + List before = project.document().snapshot(); + try { + WorkshopProject.ImportedTexture imported = project.importTexture(paintPart, png); + if (!project.document().snapshot().equals(before)) { + history.record(before, null); + } + RuntimeAssets.refreshTexture(textureId(imported.name())); + // Glass is worked out from the texture, so the model's split has to be redone. + MeshCache.INSTANCE.clear(); + assetPath = ""; + changed(); + rebuildWidgets(); + List warnings = imported.checked().warnings(); + say(warnings.isEmpty() ? Component.translatable("gui.mcheli.workshop.paint.imported", imported.name() + ".png") + : Component.literal(warnings.getFirst()), warnings.isEmpty() ? DIM : WARNING); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), INVALID); + } + } + + // The Sound tab + + private Component slotName(WorkshopProject.SoundSlot slot) { + return switch (slot.kind()) { + case ENGINE -> Component.translatable("gui.mcheli.workshop.sound.engine"); + case ROTOR -> Component.translatable("gui.mcheli.workshop.sound.rotor"); + case WEAPON -> Component.literal(slot.weapon()); + }; + } + + private void soundList() { + List slots = project.soundSlots(); + if (!slots.contains(soundSlot)) { + soundSlot = slots.getFirst(); + } + int rows = listRows(); + listScroll = Math.clamp(listScroll, 0, Math.max(slots.size() - rows, 0)); + for (int i = listScroll; i < slots.size() && i < listScroll + rows; i++) { + WorkshopProject.SoundSlot slot = slots.get(i); + Button row = addRenderableWidget(Button.builder(slotName(slot), b -> { + soundSlot = slot; + panelScroll = 0; + rebuildWidgets(); + }).bounds(2, TOP + 2 + (i - listScroll) * LIST_ROW, LEFT - 4, LIST_ROW).build()); + row.active = !slot.equals(soundSlot); + } + } + + /** The sound the game would play for a slot, or empty for none. */ + private String effectiveSound(WorkshopProject.SoundSlot slot) { + String own = project.sound(slot); + if (own != null) { + return own; + } + return switch (slot.kind()) { + case ENGINE -> project.definition().soundOrDefault(); + case ROTOR -> ""; + case WEAPON -> { + WeaponDefinition weapon = MCHeli.content().weapon(slot.weapon()); + yield weapon != null ? weapon.sound : ""; + } + }; + } + + private void soundPanel() { + Rows rows = new Rows(); + String own = project.sound(soundSlot); + rows.label(slotName(soundSlot), TEXT); + rows.label(trimmed(own != null ? Component.translatable("gui.mcheli.workshop.sound.current", own + ".ogg") : Component.translatable(switch (soundSlot.kind()) { + case ENGINE -> "gui.mcheli.workshop.sound.default"; + case ROTOR -> "gui.mcheli.workshop.sound.none"; + case WEAPON -> "gui.mcheli.workshop.sound.weapon_default"; + }), rows.w), DIM); + assetRow(rows, "gui.mcheli.workshop.sound.file", path -> importSound(path)); + if (rows.shows(FIELD_ROW)) { + int half = rows.w / 2 - 1; + Button play = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.sound.play"), b -> playSlot()) + .bounds(rows.x, rows.y, half, FIELD_ROW - 2).build()); + play.active = !effectiveSound(soundSlot).isEmpty(); + Button clear = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.clear"), b -> { + history.record(project.document().snapshot(), null); + project.setSound(soundSlot, null); + changed(); + rebuildWidgets(); + }).bounds(rows.x + rows.w - half, rows.y, half, FIELD_ROW - 2).build()); + clear.active = own != null; + } + rows.next(); + switch (soundSlot.kind()) { + case ENGINE -> loopControls(rows, "SoundVolume", project.definition().soundVolume, "SoundPitchCurve"); + case ROTOR -> loopControls(rows, "RotorSoundVolume", project.definition().rotorSoundVolume, "RotorSoundPitchCurve"); + case WEAPON -> weaponPitchRow(rows); + } + hint(rows, "gui.mcheli.workshop.sound.drop"); + } + + private void loopControls(Rows rows, String volumeKey, float volume, String curveKey) { + if (rows.shows(FIELD_ROW)) { + labels.add(new Label(Component.translatable("gui.mcheli.workshop.sound.volume"), rows.x, rows.y + 5, DIM)); + numberBox(rows.x + rows.w - 58, rows.y, 58, WorkshopPart.number(volume), false, text -> { + history.record(project.document().snapshot(), volumeKey); + project.document().set(volumeKey, text.trim()); + changed(); + }); + } + rows.next(); + PitchCurve curve = curve(curveKey); + if (curve != null) { + rows.label(Component.translatable("gui.mcheli.workshop.sound.curve"), DIM); + if (rows.shows(PLOT_HEIGHT)) { + plotX = rows.x + 2; + plotY = rows.y + 2; + plotWidth = rows.w - 4; + plotHeight = PLOT_HEIGHT - 4; + } + rows.y += PLOT_HEIGHT; + panelEnd = rows.y; + } + if (rows.shows(FIELD_ROW)) { + addRenderableWidget(Button.builder(Component.translatable(curve == null ? "gui.mcheli.workshop.sound.curve_add" : "gui.mcheli.workshop.sound.curve_remove"), b -> { + history.record(project.document().snapshot(), null); + project.document().set(curveKey, curve == null ? DEFAULT_CURVE : null); + changed(); + rebuildWidgets(); + }).bounds(rows.x, rows.y, rows.w, FIELD_ROW - 2).build()); + } + rows.next(); + } + + private void weaponPitchRow(Rows rows) { + WeaponSound own = project.definition().weaponSounds.get(soundSlot.weapon()); + if (own != null && rows.shows(FIELD_ROW)) { + labels.add(new Label(Component.translatable("gui.mcheli.workshop.sound.pitch"), rows.x, rows.y + 5, DIM)); + String shown = own.pitch() != null ? WorkshopPart.number(own.pitch()) : "1"; + numberBox(rows.x + rows.w - 58, rows.y, 58, shown, false, text -> { + history.record(project.document().snapshot(), "WeaponSound " + soundSlot.weapon()); + project.document().setKeyed("WeaponSound", soundSlot.weapon(), soundSlot.weapon() + ", " + own.sound() + ", " + text.trim()); + changed(); + }); + } + rows.next(); + } + + private String curveKey() { + return soundSlot.kind() == WorkshopProject.SoundKind.ROTOR ? "RotorSoundPitchCurve" : "SoundPitchCurve"; + } + + private @Nullable PitchCurve curve(String key) { + String text = project.document().value(key); + if (text == null) { + return null; + } + try { + return PitchCurve.parse(text); + } catch (IllegalArgumentException e) { + // Mistyped by hand. The line is reported below the view, and there is nothing to draw. + return null; + } + } + + private void importSound(Path ogg) { + List before = project.document().snapshot(); + try { + WorkshopProject.ImportedSound imported = project.importSound(soundSlot, ogg); + if (!project.document().snapshot().equals(before)) { + history.record(before, null); + } + assetPath = ""; + changed(); + rebuildWidgets(); + say(imported.warnings().isEmpty() ? Component.translatable("gui.mcheli.workshop.sound.imported", imported.name() + ".ogg") + : Component.literal(imported.warnings().getFirst()), imported.warnings().isEmpty() ? DIM : WARNING); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), INVALID); + } + } + + /** Plays the slot's sound once, at full throttle if it has a curve, where the player stands. */ + private void playSlot() { + String sound = effectiveSound(soundSlot); + if (sound.isEmpty() || minecraft.player == null) { + return; + } + float pitch = 1.0F; + if (soundSlot.kind() != WorkshopProject.SoundKind.WEAPON) { + PitchCurve curve = curve(curveKey()); + pitch = curve != null ? curve.at(1.0F) : 1.0F; + } + minecraft.getSoundManager().play(new OneShotSound(sound, minecraft.player.getX(), minecraft.player.getY(), minecraft.player.getZ(), 1.0F, pitch)); + } + + /** A file on the window goes wherever the tab that is open can take it. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty()) { + return; + } + switch (tab) { + case PAINT -> importTexture(files.getFirst()); + case SOUND -> importSound(files.getFirst()); + default -> say(Component.translatable("gui.mcheli.workshop.drop_where"), WARNING); + } + } + + private double plotPointX(PitchCurve curve, int point) { + return plotX + curve.throttle(point) * plotWidth; + } + + private double plotPointY(float pitch) { + return plotY + plotHeight * (1.0F - (pitch - PitchCurve.MIN_PITCH) / (PitchCurve.MAX_PITCH - PitchCurve.MIN_PITCH)); + } + + private int curvePointAt(double x, double y) { + PitchCurve curve = tab == Tab.SOUND && plotHeight > 0 ? curve(curveKey()) : null; + if (curve == null) { + return -1; + } + for (int i = 0; i < curve.size(); i++) { + if (Math.hypot(x - plotPointX(curve, i), y - plotPointY(curve.pitch(i))) < PICK_DISTANCE) { + return i; + } + } + return -1; + } + + private void dragCurve(double y) { + PitchCurve curve = curve(curveKey()); + if (curve == null || curvePoint >= curve.size()) { + return; + } + float f = 1.0F - (float) (y - plotY) / plotHeight; + float pitch = PitchCurve.MIN_PITCH + f * (PitchCurve.MAX_PITCH - PitchCurve.MIN_PITCH); + history.record(project.document().snapshot(), "drag" + nextDrag); + project.document().set(curveKey(), curve.withPitch(curvePoint, Math.round(pitch * PITCH_SNAP) / PITCH_SNAP).text()); + changed(); + } + + private void plot(GuiGraphicsExtractor graphics) { + PitchCurve curve = plotHeight > 0 ? curve(curveKey()) : null; + if (curve == null) { + return; + } + graphics.fill(plotX, plotY, plotX + plotWidth, plotY + plotHeight, VIEW); + // Normal pitch, for scale. + graphics.fill(plotX, (int) plotPointY(1.0F), plotX + plotWidth, (int) plotPointY(1.0F) + 1, GRID); + for (int i = 1; i < curve.size(); i++) { + segment(graphics, plotPointX(curve, i - 1), plotPointY(curve.pitch(i - 1)), plotPointX(curve, i), plotPointY(curve.pitch(i)), Y_COLOR); + } + for (int i = 0; i < curve.size(); i++) { + square(graphics, plotPointX(curve, i), plotPointY(curve.pitch(i)), HANDLE_RADIUS, drag == Drag.CURVE && curvePoint == i ? SELECTED : Y_COLOR); + } } private void manifest(PackManifest manifest) { @@ -806,6 +1221,15 @@ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { if (super.mouseClicked(event, doubleClick)) { return true; } + int point = curvePointAt(event.x(), event.y()); + if (point >= 0) { + setFocused(null); + drag = Drag.CURVE; + curvePoint = point; + nextDrag++; + history.endMerge(); + return true; + } if (!overView(event.x(), event.y())) { return false; } @@ -902,6 +1326,7 @@ public boolean mouseDragged(MouseButtonEvent event, double dx, double dy) { } case ORBIT -> camera.orbit(dx, dy); case PAN -> camera.pan(dx, dy); + case CURVE -> dragCurve(event.y()); case PLANE, AXIS_X, AXIS_Y, AXIS_Z -> { dragX += dx; dragY += dy; @@ -1047,6 +1472,11 @@ public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mo for (WrappedLine line : wrapped) { graphics.text(font, line.text(), line.x(), line.y(), DIM); } + if (previewTexture != null) { + graphics.blit(RenderPipelines.GUI_TEXTURED, previewTexture, previewX, previewY, 0.0F, 0.0F, previewWidth, previewHeight, + previewTextureWidth, previewTextureHeight, previewTextureWidth, previewTextureHeight); + } + plot(graphics); statusLine(graphics); } @@ -1055,7 +1485,11 @@ private void statusLine(GuiGraphicsExtractor graphics) { int y = viewBottom() + 3; Component first; int color; - if (message != null) { + int sending = WorkshopClient.submitProgress(); + if (sending >= 0) { + first = Component.translatable("gui.mcheli.workshop.submit_sending", sending); + color = DIM; + } else if (message != null) { first = message; color = messageColor; } else if (!project.problems().isEmpty()) { diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java index bf89ea5a..28f14851 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.network.PackDataPacket; import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; import net.minecraft.world.entity.Entity; import org.jspecify.annotations.Nullable; @@ -22,6 +23,10 @@ public void onPackList(PackListPacket packet) { @Override public void onPackData(PackDataPacket packet) { } + + @Override + public void onPackSound(PackSoundPacket packet) { + } }; private ClientAccess() { @@ -46,5 +51,7 @@ public interface PackReceiver { void onPackList(PackListPacket packet); void onPackData(PackDataPacket packet); + + void onPackSound(PackSoundPacket packet); } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java index de94baf3..7b04e4a4 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -4,10 +4,12 @@ import com.jnhprojects.mcheli.ServerConfig; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackManifest; import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; import net.minecraft.core.component.DataComponents; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; @@ -18,6 +20,8 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.Level; import net.minecraft.world.level.gameevent.GameEvent; @@ -26,6 +30,8 @@ import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; +import java.util.function.Consumer; + /** * Places a vehicle, the same way a boat is placed. * @@ -82,6 +88,21 @@ public Component getName(ItemStack stack) { return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); } + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, Consumer out, TooltipFlag flag) { + super.appendHoverText(stack, context, display, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.accept((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + @Override public InteractionResult use(Level level, Player player, InteractionHand hand) { ItemStack stack = player.getItemInHand(hand); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java index 45cacb36..26065705 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -21,7 +21,7 @@ */ public final class ModNetwork { /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - public static final int PROTOCOL_VERSION = 10; + public static final int PROTOCOL_VERSION = 11; /** Client to server. The handler gets the sending player. */ public static final List> SERVERBOUND = List.of( @@ -36,7 +36,8 @@ public final class ModNetwork { new Serverbound<>(FireWeaponPacket.TYPE, FireWeaponPacket.STREAM_CODEC, FireWeaponPacket::handle), new Serverbound<>(BoardVehiclePacket.TYPE, BoardVehiclePacket.STREAM_CODEC, BoardVehiclePacket::handle), new Serverbound<>(LockWarningPacket.TYPE, LockWarningPacket.STREAM_CODEC, LockWarningPacket::handle), - new Serverbound<>(PackRequestPacket.TYPE, PackRequestPacket.STREAM_CODEC, PackRequestPacket::handle)); + new Serverbound<>(PackRequestPacket.TYPE, PackRequestPacket.STREAM_CODEC, PackRequestPacket::handle), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); /** Server to client. Handlers touch client classes, so they only ever run on a client. */ public static final List> CLIENTBOUND = List.of( @@ -44,6 +45,7 @@ public final class ModNetwork { new Clientbound<>(ExplosionEffectPacket.TYPE, ExplosionEffectPacket.STREAM_CODEC, ExplosionEffectPacket::handle), new Clientbound<>(PackListPacket.TYPE, PackListPacket.STREAM_CODEC, PackListPacket::handle), new Clientbound<>(PackDataPacket.TYPE, PackDataPacket.STREAM_CODEC, PackDataPacket::handle), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::handle)); private ModNetwork() { diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..b25eba0a --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

+ *  Submit in the workshop   ── PackSubmitPacket(chunk) ──▶  until the whole zip is here
+ *  checked off the main thread, kept in mcheli/pending, operators told
+ *  /mcheli approve <pack>    moves it to mcheli/packs and reloads, so every player gets it
+ * 
+ * Nothing a client sends is trusted. Submissions are off until the server's owner turns them on, one + * zip can be no larger than the server allows for a pack, a player sends one at a time with a pause + * in between, chunks out of order end the transfer, and a few transfers run at once at most. The + * bytes stay in memory only while they arrive; {@link PendingPacks} checks them as it would any pack. + * + *

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.nonCriticalIoPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java index fe9c668a..b3e53269 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -43,10 +43,12 @@ public static void onLogin(ServerPlayer player) { if (seesProblems(player)) { MCHeli.packs().reportProblems(player::sendSystemMessage); } + PackSubmissions.onLogin(player); } public static void onLogout(ServerPlayer player) { TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); } /** The packs the server uses now, as the client should load them. */ diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java index 5fbad7b7..3f867744 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -93,14 +93,19 @@ private static Vec3 forward(float yaw, float pitch) { } private static void sound(WeaponSet set, Entity at) { - sound(set, at, set.definition().sound); + sound(set, at, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; } private static void sound(WeaponSet set, Entity at, String name) { if (set.soundAllowed) { WeaponDefinition w = set.definition(); float rnd = w.soundPitchRandom; - ModSounds.playAt(at, name, w.soundVolume, w.soundPitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); } } @@ -337,7 +342,7 @@ private static boolean targetingPod(ServerLevel level, WeaponSet set, Shot shot) if ((w.target & WeaponDefinition.TARGET_BLOCK) != 0) { // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); - sound(set, aimer, marked ? w.sound : "ng"); + sound(set, aimer, marked ? soundName(set) : "ng"); return true; } List spotted = new ArrayList<>(); @@ -352,7 +357,7 @@ private static boolean targetingPod(ServerLevel level, WeaponSet set, Shot shot) if (aimer instanceof ServerPlayer player) { Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); } - sound(set, shot.shooter, !spotted.isEmpty() ? w.sound : "ng"); + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "ng"); return true; } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java index 179ac950..85dee9f8 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -69,7 +69,9 @@ public VehicleWeapons(AircraftEntity vehicle, AircraftDefinition def) { for (WeaponGroup group : def.weaponGroups) { WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); if (weapon != null && !group.mounts().isEmpty()) { - sets.add(new WeaponSet(weapon, group.mounts(), clientSide)); + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); } } selected = new int[def.seatCount()]; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java index 2e37382a..3f539030 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.weapon; import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import com.jnhprojects.mcheli.content.weapon.WeaponType; import net.minecraft.util.Mth; @@ -56,6 +57,8 @@ public interface Trigger { public int soundWait; /** Whether the shot being fired may play its sound, see {@code SoundDelay}. */ public boolean soundAllowed = true; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; /** Aim relative to the hull, following the crew member who operates the weapon. */ public float rotationYaw; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java index 9438f032..f5786b92 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -15,8 +15,8 @@ /** * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN - * game: a dedicated server has no way to get the pack until players can submit one, so the screen - * does not offer it there and the server refuses it too. + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. */ public final class TestFlight { /** How far up to look for room when the vehicle does not fit where the player stands. */