diff --git a/.gitignore b/.gitignore index b7d0f53..a36e87d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ eclipse/ # Run directories run/ +run-*/ run-data/ logs/ *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce96ad..4a13537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project are documented here. The format follows [Semantic Versioning](https://semver.org/). Pre-releases (`1.0.0-alpha.N`) mark the phases on the way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). +## [1.1.0-beta.3] - 2026-10-30 + +Phase 7 of the road to 1.1.0: the mod on NeoForge and Fabric as well as Forge, and on Minecraft +1.21.1 as well as 26.3. Betas are not uploaded to CurseForge; download them from the GitHub +releases, one jar per loader and Minecraft version. Worlds from 1.1.0-beta.2 and 1.0.0 load as +before on 26.3. Players and servers need the same version, since the network protocol changed. + +### Added + +- NeoForge and Fabric jars for Minecraft 26.3, with the same features as the Forge one. Fabric + needs Fabric API. Worlds, the `mcheli` folder, content packs and configs carry over between + loaders. +- Minecraft 1.21.1 on Forge, NeoForge and Fabric, with the same vehicles, weapons, HUD and + content packs as on 26.3. Vehicle icons come from the kind's item model, which picks each + vehicle by `custom_model_data`. +- The log names the loader the mod runs on, next to the version. + +### Changed + +- Jar names carry the loader and the Minecraft version, such as `mcheli-forge-26.3-.jar` + and `mcheli-fabric-1.21.1-.jar`. +- The configs are read by the mod itself instead of Forge's config system, which Fabric does not + have. The files, their place and their keys stay the same, so existing settings are kept. Edits + to `serverconfig/mcheli-server.toml` now apply on the next world start or `/mcheli reload` + instead of while the server runs; `/mcheli reload` now reads the server config again too. +- The original content is imported again on the first start, since the recipes now name the + vehicle ingredient for every loader. The import writes the layout of the Minecraft version it runs + on, and imports again when the folder was made for another version. +- Every packet travels under its own id (`mcheli:pilot_state` and so on) with the same encoding on + every loader. Clients and servers need the same version, since the network protocol changed. +- Glass panes that bullets shoot through come from the `c:glass_panes` tag, which every loader + fills. + +### Developers + +- The code is split in three layers: `common` (no Minecraft), `minecraft-26.3` (Minecraft, no + loader) and a thin module per loader (`forge-26.3`, `neoforge-26.3`, `fabric-26.3`). The shared + code reaches the loader through `Platform` and `Registration`, and the loaders call + `CommonHooks` and `ClientHooks` from their events. Where NeoForge or Fabric has no event, a + mixin hooks in where Forge fires its own. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +- `minecraft-1.21.1` holds the shared code backported to 1.21.1; `forge-1.21.1`, `neoforge-1.21.1` + and `fabric-1.21.1` are its loader modules. 1.21.1 development runs use `./run-1.21.1`. +- Gradle 9.8.2, for Fabric Loom 1.19. NeoForge modules build with ModDevGradle. + ## [1.1.0-beta.2] - 2026-10-29 Phase 6 of the road to 1.1.0: content packs that servers send to their players, night glow and diff --git a/README.md b/README.md index 48dbd8e..48f853d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ MCHeli Rework icon -Helicopters, planes, tanks and ground vehicles for **Minecraft 26.3** on **Forge 66.1**. +Helicopters, planes, tanks and ground vehicles for **Minecraft 26.3** and **1.21.1**, on **Forge**, +**NeoForge** and **Fabric**. MCHeli Rework is a ground-up port of MCHeli 1.0.3, a mod that stopped at Minecraft 1.7.10. Every system is rewritten for the modern game, while the original vehicle definitions, models, @@ -124,24 +125,28 @@ Reserved original, the code is source available rather than open source. ## Requirements -| | Version | -|---|---------| -| Minecraft | 26.3 | -| Forge | 66.1.0 or newer in the 66.x line | -| Java | 25 (bundled with the Minecraft launcher) | +| | Minecraft 26.3 | Minecraft 1.21.1 | +|---|---|---| +| Forge | 66.1.0 or newer in the 66.x line | 52.1.0 or newer | +| NeoForge | 26.3.0.99 or newer | 21.1.200 or newer | +| Fabric | Loader 0.20 and Fabric API | Loader 0.16 and Fabric API | +| Java | 25 (bundled with the launcher) | 21 (bundled with the launcher) | ## Installing -1. Install Forge 66.1.0 for Minecraft 26.3. -2. Download `mcheli-26.3-.jar` from the [releases](https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases). -3. Drop it into the `mods` folder of your Forge 26.3 profile. +1. Install Forge, NeoForge or Fabric for Minecraft 26.3 or 1.21.1. On Fabric, also install + [Fabric API](https://modrinth.com/mod/fabric-api). +2. Download the jar for your loader from the + [releases](https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases): + `mcheli---.jar`, such as `mcheli-fabric-1.21.1-.jar`. +3. Drop it into the `mods` folder of that profile. 4. Download `MC1.7.10_mcheli_1.0.3.zip` from the [MCHeli CurseForge page](https://www.curseforge.com/minecraft/mc-mods/mcheli-minecraft-helicopter-mod/files) and put it, still zipped, into a folder called `mcheli` next to `mods` (create it if needed): ``` / - mods/mcheli-26.3-.jar + mods/mcheli---.jar mcheli/MC1.7.10_mcheli_1.0.3.zip ``` @@ -151,8 +156,10 @@ be switched off. The zip can be deleted afterwards. Dedicated servers need the z place. Without it the game still starts, but there are no vehicles, and the chat says where the zip goes. -Use a separate game directory for the Forge 26.3 profile. Mods built for other loaders or older -versions in a shared `mods` folder will stop the game from starting. +Use a separate game directory for each loader and version. Mods built for other loaders or older +versions in a shared `mods` folder will stop the game from starting. Worlds and the `mcheli` +folder can move between loaders on the same Minecraft version: the imported content, content packs +and configs are the same files on all three. Do not open a 26.3 world in 1.21.1. ## Controls @@ -279,8 +286,8 @@ adds a small helicopter, the Rework Scout. To make your own, see ./gradlew build ``` -The jars, one per Minecraft version, end up in `build/release`. Java 25 is required; Gradle -downloads one automatically if it is not installed. `./gradlew :forge-26.3:runClient` and -`:forge-26.3:runServer` import the content from +The jars, one per loader and Minecraft version, end up in `build/release`. Java 25 is required; +Gradle downloads one automatically if it is not installed. `./gradlew :-26.3:runClient` +and `:-26.3:runServer` (`forge`, `neoforge` or `fabric`) import the content from `Mod/MC1.7.10_mcheli_1.0.3.zip`, which you have to download yourself. See [DEVELOPMENT.md](docs/DEVELOPMENT.md) for details. diff --git a/build.gradle b/build.gradle index e4aa2c3..8be37ce 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,24 @@ plugins { id 'base' id 'net.minecraftforge.gradle' version '[7.0.17,8)' apply false + id 'net.neoforged.moddev' version '2.0.148' apply false + // Loom 1.19 needs Gradle 9.8. The plain plugin is for unobfuscated Minecraft (26.x), the remap + // one for versions that still ship obfuscated (1.21.1 and older). + id 'net.fabricmc.fabric-loom' version '1.19.3' apply false + id 'net.fabricmc.fabric-loom-remap' version '1.19.3' apply false } def platforms = providers.gradleProperty('platforms').get().split(',').collect { it.trim() } -// One jar per Minecraft version, gathered in one folder for the release workflow. +// One jar per loader and Minecraft version, gathered in one folder for the release workflow. def releaseJars = tasks.register('releaseJars', Sync) { group = 'build' - description = 'Collects the jar of every platform module into build/release.' - platforms.each { platform -> from(project(":$platform").tasks.named('jar')) } + description = 'Collects the jar of every loader module into build/release.' + platforms.each { platform -> + // On the obfuscated versions (1.x) Fabric's shippable jar is the remapped one. + def jarTask = platform.startsWith('fabric-1.') ? 'remapJar' : 'jar' + from(project(":$platform").tasks.named(jarTask)) + } into layout.buildDirectory.dir('release') } diff --git a/common/src/main/java/com/jnhprojects/mcheli/config/ConfigSpec.java b/common/src/main/java/com/jnhprojects/mcheli/config/ConfigSpec.java new file mode 100644 index 0000000..8f8b002 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/config/ConfigSpec.java @@ -0,0 +1,384 @@ +package com.jnhprojects.mcheli.config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * A small TOML config with the layout Forge's config specs wrote: {@code [section]} tables, flat + * keys, comments above each key. Files written by MCHeli Rework 1.0.0 and the first betas keep + * their values. Fabric has no config system of its own, so every loader uses this one. + * + *

Reading never fails: a missing file, an unknown key or a bad value gives the default, and + * {@link #load(Path)} writes the file back with every key and comment in place, like Forge did. + * Until the first load, and after {@link #unload()}, every value is its default. + */ +public final class ConfigSpec { + private final List

sections; + private volatile boolean loaded; + + private ConfigSpec(List
sections) { + this.sections = List.copyOf(sections); + } + + public static Builder builder() { + return new Builder(); + } + + public boolean isLoaded() { + return loaded; + } + + /** Reads {@code file}, or the defaults if it is missing or unreadable, then writes it back complete. */ + public void load(Path file) throws IOException { + Map raw = Files.isRegularFile(file) ? parse(Files.readString(file, StandardCharsets.UTF_8)) : Map.of(); + for (Section section : sections) { + for (Value value : section.values) { + value.read(raw.get(section.name + "." + value.key)); + } + } + loaded = true; + Files.createDirectories(file.toAbsolutePath().getParent()); + Files.writeString(file, write(), StandardCharsets.UTF_8); + } + + /** Back to the defaults, for when the world that owns the file closes. */ + public void unload() { + loaded = false; + for (Section section : sections) { + for (Value value : section.values) { + value.reset(); + } + } + } + + /** The file as {@link #load(Path)} writes it. */ + public String write() { + StringBuilder out = new StringBuilder(); + for (Section section : sections) { + if (!out.isEmpty()) { + out.append('\n'); + } + for (String line : section.comment) { + out.append('#').append(line).append('\n'); + } + out.append('[').append(section.name).append("]\n"); + for (Value value : section.values) { + for (String line : value.comment) { + out.append("\t#").append(line).append('\n'); + } + if (value.range != null) { + out.append("\t#Range: ").append(value.range).append('\n'); + } + out.append('\t').append(value.key).append(" = ").append(value.format()).append('\n'); + } + } + return out.toString(); + } + + /** + * Flattens a TOML document into {@code section.key -> raw value}. Only what this class writes + * is understood: tables, bare keys, and single or multi-line arrays. + */ + static Map parse(String text) { + Map values = new LinkedHashMap<>(); + String section = ""; + String[] lines = text.split("\\R"); + for (int i = 0; i < lines.length; i++) { + String line = stripComment(lines[i]).trim(); + if (line.isEmpty()) { + continue; + } + if (line.startsWith("[") && line.endsWith("]")) { + section = line.substring(1, line.length() - 1).trim(); + continue; + } + int eq = line.indexOf('='); + if (eq <= 0) { + continue; + } + String key = line.substring(0, eq).trim(); + StringBuilder value = new StringBuilder(line.substring(eq + 1).trim()); + // An array may run over several lines. + while (value.toString().startsWith("[") && !closes(value) && i + 1 < lines.length) { + value.append(' ').append(stripComment(lines[++i]).trim()); + } + values.put(section.isEmpty() ? key : section + "." + key, value.toString()); + } + return values; + } + + private static boolean closes(CharSequence array) { + return array.toString().stripTrailing().endsWith("]"); + } + + /** Drops a {@code #} comment, unless the {@code #} is inside a quoted string. */ + private static String stripComment(String line) { + boolean quoted = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == '"' && (i == 0 || line.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (c == '#' && !quoted) { + return line.substring(0, i); + } + } + return line; + } + + private static String quote(String s) { + return '"' + s.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; + } + + private static String unquote(String s) { + String t = s.trim(); + if (t.length() >= 2 && (t.startsWith("\"") && t.endsWith("\"") || t.startsWith("'") && t.endsWith("'"))) { + return t.substring(1, t.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); + } + throw new IllegalArgumentException("Not a string: " + s); + } + + private record Section(String name, List comment, List> values) { + } + + /** One setting. {@link #get()} is safe from any thread. */ + public abstract static sealed class Value permits BooleanValue, IntValue, DoubleValue, StringListValue { + private final String key; + private final List comment; + private final T defaultValue; + private final String range; + private volatile T value; + + Value(String key, List comment, T defaultValue, String range) { + this.key = key; + this.comment = comment; + this.defaultValue = defaultValue; + this.range = range; + this.value = defaultValue; + } + + public T get() { + return value; + } + + public T getDefault() { + return defaultValue; + } + + void read(String raw) { + if (raw == null) { + value = defaultValue; + return; + } + try { + value = Objects.requireNonNull(parse(raw.trim())); + } catch (RuntimeException e) { + value = defaultValue; + } + } + + void reset() { + value = defaultValue; + } + + abstract T parse(String raw); + + abstract String format(); + } + + public static final class BooleanValue extends Value { + BooleanValue(String key, List comment, boolean defaultValue) { + super(key, comment, defaultValue, null); + } + + @Override + Boolean parse(String raw) { + return switch (raw.toLowerCase(Locale.ROOT)) { + case "true" -> true; + case "false" -> false; + default -> throw new IllegalArgumentException(raw); + }; + } + + @Override + String format() { + return get().toString(); + } + } + + public static final class IntValue extends Value { + private final int min; + private final int max; + + IntValue(String key, List comment, int defaultValue, int min, int max) { + super(key, comment, defaultValue, min + " ~ " + max); + this.min = min; + this.max = max; + } + + @Override + Integer parse(String raw) { + int v = Integer.parseInt(raw); + if (v < min || v > max) { + throw new IllegalArgumentException(raw); + } + return v; + } + + @Override + String format() { + return get().toString(); + } + } + + public static final class DoubleValue extends Value { + private final double min; + private final double max; + + DoubleValue(String key, List comment, double defaultValue, double min, double max) { + super(key, comment, defaultValue, min + " ~ " + max); + this.min = min; + this.max = max; + } + + @Override + Double parse(String raw) { + double v = Double.parseDouble(raw); + if (!(v >= min && v <= max)) { + throw new IllegalArgumentException(raw); + } + return v; + } + + @Override + String format() { + return get().toString(); + } + } + + public static final class StringListValue extends Value> { + StringListValue(String key, List comment, List defaultValue) { + super(key, comment, List.copyOf(defaultValue), null); + } + + @Override + List parse(String raw) { + if (!raw.startsWith("[") || !raw.endsWith("]")) { + throw new IllegalArgumentException(raw); + } + String body = raw.substring(1, raw.length() - 1).trim(); + List items = new ArrayList<>(); + if (body.isEmpty()) { + return List.of(); + } + for (String item : splitArray(body)) { + if (!item.isBlank()) { + items.add(unquote(item)); + } + } + return List.copyOf(items); + } + + /** Splits on commas outside quotes. */ + private static List splitArray(String body) { + List parts = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + if (c == '"' && (i == 0 || body.charAt(i - 1) != '\\')) { + quoted = !quoted; + } + if (c == ',' && !quoted) { + parts.add(current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + parts.add(current.toString()); + return parts; + } + + @Override + String format() { + List quoted = new ArrayList<>(); + for (String s : get()) { + quoted.add(quote(s)); + } + return "[" + String.join(", ", quoted) + "]"; + } + } + + /** Collects sections and values in file order. */ + public static final class Builder { + private final List
sections = new ArrayList<>(); + private Section current; + private List pendingComment = List.of(); + + private Builder() { + } + + /** The comment for the next section or value. */ + public Builder comment(String... lines) { + List spaced = new ArrayList<>(); + for (String line : lines) { + spaced.add(" " + line); + } + pendingComment = List.copyOf(spaced); + return this; + } + + public Builder push(String section) { + current = new Section(section, takeComment(), new ArrayList<>()); + sections.add(current); + return this; + } + + public Builder pop() { + current = null; + return this; + } + + public BooleanValue define(String key, boolean defaultValue) { + return add(new BooleanValue(key, takeComment(), defaultValue)); + } + + public IntValue defineInRange(String key, int defaultValue, int min, int max) { + return add(new IntValue(key, takeComment(), defaultValue, min, max)); + } + + public DoubleValue defineInRange(String key, double defaultValue, double min, double max) { + return add(new DoubleValue(key, takeComment(), defaultValue, min, max)); + } + + public StringListValue defineList(String key, List defaultValue) { + return add(new StringListValue(key, takeComment(), defaultValue)); + } + + public ConfigSpec build() { + return new ConfigSpec(sections); + } + + private List takeComment() { + List comment = pendingComment; + pendingComment = List.of(); + return comment; + } + + private > V add(V value) { + if (current == null) { + throw new IllegalStateException("Values belong in a section; call push first"); + } + current.values.add(value); + return value; + } + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java b/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java new file mode 100644 index 0000000..0753c4f --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java @@ -0,0 +1,19 @@ +package com.jnhprojects.mcheli.content; + +/** + * The pack layout {@link LegacyImporter} writes. Resource and data formats changed between the + * supported Minecraft versions, so each version's module asks for its own. + */ +public enum ImportTarget { + /** + * Minecraft 26.x: every vehicle's item model has an item model definition, a crafted stack names + * it in the {@code item_model} component, and recipe ingredients are plain ids and {@code #tags}. + */ + MODERN, + /** + * Minecraft 1.21.1: there is no {@code item_model} component, so each kind's item model picks + * the vehicle's model by {@code custom_model_data} (see {@link LegacyImporter#ICONS_FILE}), and + * recipe ingredients are {@code {"item": ...}} and {@code {"tag": ...}} objects. + */ + V1_21_1 +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java b/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java index 1daae4b..1713018 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.content; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.recipe.LegacyRecipe; import java.io.IOException; @@ -44,9 +45,16 @@ public final class LegacyImporter { /** * Bump whenever the output changes, so existing installs import again. 2: recipes craft the - * generic vehicle items of 1.1.0. + * generic vehicle items of 1.1.0. 3: vehicle ingredients name their type for every loader, and + * the output depends on the {@link ImportTarget}. */ - public static final int FORMAT = 2; + public static final int FORMAT = 3; + + /** + * Written at the pack root for {@link ImportTarget#V1_21_1}: each original vehicle's + * {@code custom_model_data}, as {@code name=index}, which the mod puts on the vehicle's stack. + */ + public static final String ICONS_FILE = "item_icons.properties"; private static final String ASSET_ROOT = "mcheli/assets/mcheli/"; private static final Set SKIPPED_EXTENSIONS = Set.of("xcf"); @@ -55,6 +63,8 @@ public final class LegacyImporter { private static final Pattern LANG_TILE = Pattern.compile("^tile\\.(.+)\\.name$"); private static final Pattern LANG_ACHIEVEMENT = Pattern.compile("^achievement\\.McHeli(\\w+?)(\\.desc)?$"); + private final ImportTarget target; + private final Path packRoot; private final Path assetsOut; private final Path recipesOut; private final Map> translations = new TreeMap<>(); @@ -62,7 +72,9 @@ public final class LegacyImporter { private final Set itemTextures = new TreeSet<>(); private int copied; - private LegacyImporter(Path packRoot) { + private LegacyImporter(Path packRoot, ImportTarget target) { + this.target = target; + this.packRoot = packRoot; this.assetsOut = packRoot.resolve("assets/mcheli"); this.recipesOut = packRoot.resolve("data/mcheli/recipe"); } @@ -73,7 +85,12 @@ private LegacyImporter(Path packRoot) { * @throws IOException when the zip cannot be read or holds no {@code mcheli/assets/mcheli} folder */ public static Summary run(Path zip, Path packRoot) throws IOException { - return new LegacyImporter(packRoot).importFrom(zip); + return run(zip, packRoot, ImportTarget.MODERN); + } + + /** As above, in the layout of {@code target}. */ + public static Summary run(Path zip, Path packRoot, ImportTarget target) throws IOException { + return new LegacyImporter(packRoot, target).importFrom(zip); } private Summary importFrom(Path zipPath) throws IOException { @@ -97,7 +114,9 @@ private Summary importFrom(Path zipPath) throws IOException { writeItemModels(); writeTranslations(); - LegacyRecipe.Generated recipes = LegacyRecipe.generate(ContentRegistry.load(assetsOut)); + ContentRegistry content = ContentRegistry.load(assetsOut); + Map icons = target == ImportTarget.V1_21_1 ? writeKindModels(content) : Map.of(); + LegacyRecipe.Generated recipes = LegacyRecipe.generate(content, target, icons); for (Map.Entry e : recipes.files().entrySet()) { write(recipesOut.resolve(e.getKey()), e.getValue()); } @@ -257,6 +276,40 @@ private void writeItemModels() throws IOException { } } + /** + * 1.21.1: one item model per kind, which shows a barrier unless the stack's + * {@code custom_model_data} picks a vehicle. Vehicles are numbered from 1 in name order within + * their kind; one without an item texture keeps the barrier. + * + * @return each vehicle's number, also written to {@link #ICONS_FILE} + */ + private Map writeKindModels(ContentRegistry content) throws IOException { + Map> byKind = new TreeMap<>(); + for (AircraftDefinition def : content.all()) { + byKind.computeIfAbsent(def.kind(), k -> new ArrayList<>()).add(def.name()); + } + Map icons = new TreeMap<>(); + for (Map.Entry> e : byKind.entrySet()) { + List names = e.getValue(); + names.sort(null); + StringBuilder json = new StringBuilder("{\n \"parent\": \"minecraft:item/barrier\",\n \"overrides\": [\n"); + for (int i = 0; i < names.size(); i++) { + String name = names.get(i); + icons.put(name, i + 1); + String model = itemTextures.contains(name) ? "mcheli:item/" + name : "minecraft:item/barrier"; + json.append(" {\"predicate\": {\"custom_model_data\": ").append(i + 1).append("}, \"model\": \"") + .append(model).append("\"}").append(i + 1 < names.size() ? "," : "").append('\n'); + } + write(assetsOut.resolve("models/item/" + e.getKey() + ".json"), json.append(" ]\n}\n").toString()); + } + StringBuilder list = new StringBuilder("# Written by MCHeli Rework: the custom_model_data of each vehicle's icon\n"); + for (Map.Entry e : icons.entrySet()) { + list.append(e.getKey()).append('=').append(e.getValue()).append('\n'); + } + write(packRoot.resolve(ICONS_FILE), list.toString()); + return icons; + } + private void writeTranslations() throws IOException { for (Map.Entry> e : translations.entrySet()) { StringBuilder json = new StringBuilder("{\n"); diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java index 79accd8..25d9e5a 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.content.recipe; import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.ImportTarget; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import java.util.ArrayList; @@ -37,6 +38,12 @@ private LegacyRecipe() { public static final String VEHICLE_COMPONENT = "mcheli:vehicle"; /** Ingredient type matching one vehicle, since every vehicle of a kind shares an item. */ public static final String VEHICLE_INGREDIENT = "mcheli:vehicle"; + /** + * The keys naming a custom ingredient's type on Forge, NeoForge and Fabric. A vehicle + * ingredient carries all three, so the imported recipes work whichever loader reads them; each + * loader ignores the other two. + */ + public static final List INGREDIENT_TYPE_KEYS = List.of("type", "neoforge:ingredient_type", "fabric:type"); /** * @param result the crafted vehicle @@ -46,18 +53,28 @@ private LegacyRecipe() { * @throws InvalidRecipeException with the reason when the line cannot become a recipe */ public static String toJson(Vehicle result, String line, boolean shaped, Predicate modItems) { + return toJson(result, line, shaped, modItems, ImportTarget.MODERN); + } + + /** As above, in the recipe format of {@code target}. */ + public static String toJson(Vehicle result, String line, boolean shaped, Predicate modItems, ImportTarget target) { String[] s = line.trim().split("\\s*,\\s*"); - return shaped ? shaped(result, s, modItems) : shapeless(result, s, modItems); + return shaped ? shaped(result, s, modItems, target) : shapeless(result, s, modItems, target); } /** * A crafted vehicle: the generic item of its kind ({@code mcheli:helicopter}) carrying the * definition name, and the item model the import wrote for it. + * + * @param icon the vehicle's {@code custom_model_data} on 1.21.1, 0 for none */ - public record Vehicle(String kind, String name) { + public record Vehicle(String kind, String name, int icon) { + public Vehicle(String kind, String name) { + this(kind, name, 0); + } } - private static String shaped(Vehicle result, String[] s, Predicate modItems) { + private static String shaped(Vehicle result, String[] s, Predicate modItems, ImportTarget target) { int idx = 0; int count = 1; if (s.length > 0 && isNumber(s[0])) { @@ -96,7 +113,7 @@ private static String shaped(Vehicle result, String[] s, Predicate modIt if (idx < s.length && isNumber(s[idx])) { damage = Integer.parseInt(s[idx++]); } - key.put(Character.toUpperCase(symbol.charAt(0)), ingredient(name, damage, modItems)); + key.put(Character.toUpperCase(symbol.charAt(0)), ingredient(name, damage, modItems, target)); } // 1.7.10 left a pattern letter the key does not define as an empty slot; two bundled @@ -131,11 +148,11 @@ private static String shaped(Vehicle result, String[] s, Predicate modIt json.append(" \"").append(rows.get(i)).append('"').append(i + 1 < rows.size() ? ",\n" : "\n"); } json.append(" ],\n"); - appendResult(json, result, count); + appendResult(json, result, count, target); return json.toString(); } - private static String shapeless(Vehicle result, String[] s, Predicate modItems) { + private static String shapeless(Vehicle result, String[] s, Predicate modItems, ImportTarget target) { List ingredients = new ArrayList<>(); for (int idx = 0; idx < s.length; idx++) { if (s[idx].isEmpty()) { @@ -146,7 +163,7 @@ private static String shapeless(Vehicle result, String[] s, Predicate mo if (idx + 1 < s.length && isNumber(s[idx + 1])) { damage = Integer.parseInt(s[++idx]); } - ingredients.add(ingredient(name, damage, modItems)); + ingredients.add(ingredient(name, damage, modItems, target)); } if (ingredients.isEmpty() || ingredients.size() > 9) { throw new InvalidRecipeException("a shapeless recipe needs one to nine ingredients"); @@ -157,35 +174,51 @@ private static String shapeless(Vehicle result, String[] s, Predicate mo json.append(" ").append(ingredients.get(i)).append(i + 1 < ingredients.size() ? ",\n" : "\n"); } json.append(" ],\n"); - appendResult(json, result, 1); + appendResult(json, result, 1, target); return json.toString(); } - private static void appendResult(StringBuilder json, Vehicle result, int count) { + private static void appendResult(StringBuilder json, Vehicle result, int count, ImportTarget target) { json.append(" \"result\": {\n \"id\": \"mcheli:").append(result.kind()).append('"'); if (count > 1) { json.append(",\n \"count\": ").append(count); } json.append(",\n \"components\": {\n") .append(" \"").append(VEHICLE_COMPONENT).append("\": \"").append(result.name()).append("\",\n") - .append(" \"minecraft:item_model\": \"mcheli:").append(result.name()).append("\"\n") + .append(" ").append(model(result, target)).append('\n') .append(" }\n }\n}\n"); } + /** How the crafted stack points at the vehicle's item model. */ + private static String model(Vehicle result, ImportTarget target) { + return switch (target) { + case MODERN -> "\"minecraft:item_model\": \"mcheli:" + result.name() + '"'; + case V1_21_1 -> "\"minecraft:custom_model_data\": " + result.icon(); + }; + } + /** @return the ingredient as a JSON value: a quoted item id or tag, or an object for a vehicle */ - private static String ingredient(String name, int damage, Predicate modItems) { + private static String ingredient(String name, int damage, Predicate modItems, ImportTarget target) { String lower = name.trim().toLowerCase(Locale.ROOT); if (lower.startsWith("mcheli:")) { if (!modItems.test(lower)) { throw new InvalidRecipeException(lower + " does not exist (yet)"); } - return "{\"type\": \"" + VEHICLE_INGREDIENT + "\", \"vehicle\": \"" + lower.substring("mcheli:".length()) + "\"}"; + StringBuilder json = new StringBuilder("{"); + for (String key : INGREDIENT_TYPE_KEYS) { + json.append('"').append(key).append("\": \"").append(VEHICLE_INGREDIENT).append("\", "); + } + return json.append("\"vehicle\": \"").append(lower.substring("mcheli:".length())).append("\"}").toString(); } String mapped = LegacyItems.ingredient(lower, damage); if (mapped == null) { throw new InvalidRecipeException("no modern item for " + lower + (damage != 0 ? " with damage " + damage : "")); } - return '"' + mapped + '"'; + if (target == ImportTarget.MODERN) { + return '"' + mapped + '"'; + } + // 1.21.1 only reads ingredients as objects. + return mapped.startsWith("#") ? "{\"tag\": \"" + mapped.substring(1) + "\"}" : "{\"item\": \"" + mapped + "\"}"; } private static boolean isNumber(String s) { @@ -216,6 +249,15 @@ public static Map fileNames(String vehicle, List lines) * that could not become a recipe, as {@code ": "} */ public static Generated generate(ContentRegistry content) { + return generate(content, ImportTarget.MODERN, Map.of()); + } + + /** + * As above, in the recipe format of {@code target}. + * + * @param icons each vehicle's {@code custom_model_data}, for 1.21.1 + */ + public static Generated generate(ContentRegistry content, ImportTarget target, Map icons) { Set items = new TreeSet<>(); for (AircraftDefinition def : content.all()) { items.add("mcheli:" + def.name()); @@ -227,7 +269,8 @@ public static Generated generate(ContentRegistry content) { for (Map.Entry e : fileNames(def.name(), def.recipes).entrySet()) { try { files.put(e.getKey() + ".json", - toJson(new Vehicle(def.kind(), def.name()), e.getValue(), def.shapedRecipe, items::contains)); + toJson(new Vehicle(def.kind(), def.name(), icons.getOrDefault(def.name(), 0)), e.getValue(), def.shapedRecipe, + items::contains, target)); } catch (InvalidRecipeException ex) { skipped.add(e.getKey() + ": " + ex.getMessage()); } diff --git a/common/src/test/java/com/jnhprojects/mcheli/config/ConfigSpecTest.java b/common/src/test/java/com/jnhprojects/mcheli/config/ConfigSpecTest.java new file mode 100644 index 0000000..bb9ae00 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/config/ConfigSpecTest.java @@ -0,0 +1,110 @@ +package com.jnhprojects.mcheli.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +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.assertTrue; + +class ConfigSpecTest { + private final ConfigSpec.Builder builder = ConfigSpec.builder(); + private final ConfigSpec.BooleanValue breakBlocks; + private final ConfigSpec.DoubleValue damageFactor; + private final ConfigSpec.StringListValue disabled; + private final ConfigSpec.IntValue maxPackSize; + private final ConfigSpec spec; + + ConfigSpecTest() { + builder.push("explosions"); + breakBlocks = builder.comment("Whether weapons break blocks.").define("breakBlocks", true); + builder.pop(); + builder.push("vehicles"); + damageFactor = builder.defineInRange("damageFactor", 1.0, 0.0, 100.0); + disabled = builder.defineList("disabled", List.of()); + builder.pop(); + builder.push("packs"); + maxPackSize = builder.defineInRange("maxPackSize", 8, 1, 512); + builder.pop(); + spec = builder.build(); + } + + @Test + void readsTheFileForgeWrote(@TempDir Path dir) throws IOException { + Path file = dir.resolve("mcheli-server.toml"); + Files.writeString(file, """ + [explosions] + #Whether weapons and crashing vehicles break blocks. + breakBlocks = false + + [vehicles] + #Range: 0.0 ~ 100.0 + damageFactor = 0.5 + disabled = ["ah-64", "f-35b"] + + [packs] + maxPackSize = 16 + """, StandardCharsets.UTF_8); + + spec.load(file); + + assertFalse(breakBlocks.get()); + assertEquals(0.5, damageFactor.get()); + assertEquals(List.of("ah-64", "f-35b"), disabled.get()); + assertEquals(16, maxPackSize.get()); + } + + @Test + void badValuesFallBackToTheDefault(@TempDir Path dir) throws IOException { + Path file = dir.resolve("bad.toml"); + Files.writeString(file, """ + [explosions] + breakBlocks = maybe + [vehicles] + damageFactor = 1000.0 + disabled = [ "a", + "b # not a comment" ] + [packs] + maxPackSize = 0 + """, StandardCharsets.UTF_8); + + spec.load(file); + + assertTrue(breakBlocks.get()); + assertEquals(1.0, damageFactor.get()); + assertEquals(List.of("a", "b # not a comment"), disabled.get()); + assertEquals(8, maxPackSize.get()); + } + + @Test + void writesEveryKeyBackAndReadsItAgain(@TempDir Path dir) throws IOException { + Path file = dir.resolve("sub").resolve("new.toml"); + + spec.load(file); + String written = Files.readString(file, StandardCharsets.UTF_8); + spec.load(file); + + assertTrue(written.contains("\t# Whether weapons break blocks.\n\tbreakBlocks = true\n"), written); + assertTrue(written.contains("\t#Range: 1 ~ 512\n\tmaxPackSize = 8\n"), written); + assertEquals(written, Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void unloadingGoesBackToTheDefaults(@TempDir Path dir) throws IOException { + Path file = dir.resolve("c.toml"); + Files.writeString(file, "[packs]\nmaxPackSize = 32\n", StandardCharsets.UTF_8); + spec.load(file); + assertTrue(spec.isLoaded()); + + spec.unload(); + + assertFalse(spec.isLoaded()); + assertEquals(8, maxPackSize.get()); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java index 41dafb3..0d4f911 100644 --- a/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java @@ -64,7 +64,7 @@ void aVehicleIngredientMatchesThatVehicleOnly() { "category": "misc", "key": { "R": "#c:dusts/redstone", - "X": {"type": "mcheli:vehicle", "vehicle": "ah-6"} + "X": {"type": "mcheli:vehicle", "neoforge:ingredient_type": "mcheli:vehicle", "fabric:type": "mcheli:vehicle", "vehicle": "ah-6"} }, "pattern": [ "RXR" diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipeTargetTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipeTargetTest.java new file mode 100644 index 0000000..01ce208 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipeTargetTest.java @@ -0,0 +1,67 @@ +package com.jnhprojects.mcheli.content.recipe; + +import com.jnhprojects.mcheli.content.ImportTarget; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The recipe format differs per Minecraft version; these need no original zip. */ +class LegacyRecipeTargetTest { + private static final LegacyRecipe.Vehicle AH_6X = new LegacyRecipe.Vehicle("helicopter", "ah-6x", 7); + private static final String LINE = "\"RXR\", X, mcheli:ah-6, R, redstone"; + + @Test + void modernRecipesUseStringsAndTheItemModel() { + assertEquals(""" + { + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "R": "#c:dusts/redstone", + "X": {"type": "mcheli:vehicle", "neoforge:ingredient_type": "mcheli:vehicle", "fabric:type": "mcheli:vehicle", "vehicle": "ah-6"} + }, + "pattern": [ + "RXR" + ], + "result": { + "id": "mcheli:helicopter", + "components": { + "mcheli:vehicle": "ah-6x", + "minecraft:item_model": "mcheli:ah-6x" + } + } + } + """, LegacyRecipe.toJson(AH_6X, LINE, true, "mcheli:ah-6"::equals, ImportTarget.MODERN)); + } + + @Test + void minecraft1211RecipesUseObjectsAndCustomModelData() { + assertEquals(""" + { + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "R": {"tag": "c:dusts/redstone"}, + "X": {"type": "mcheli:vehicle", "neoforge:ingredient_type": "mcheli:vehicle", "fabric:type": "mcheli:vehicle", "vehicle": "ah-6"} + }, + "pattern": [ + "RXR" + ], + "result": { + "id": "mcheli:helicopter", + "components": { + "mcheli:vehicle": "ah-6x", + "minecraft:custom_model_data": 7 + } + } + } + """, LegacyRecipe.toJson(AH_6X, LINE, true, "mcheli:ah-6"::equals, ImportTarget.V1_21_1)); + } + + @Test + void minecraft1211ShapelessIngredientsAreObjectsToo() { + String json = LegacyRecipe.toJson(AH_6X, "iron_block,coal", false, name -> false, ImportTarget.V1_21_1); + assertTrue(json.contains(" {\"tag\": \"c:storage_blocks/iron\"},\n {\"item\": \"minecraft:coal\"}\n"), json); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 23ef79a..07a0159 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Architecture This document explains how MCHeli Rework is organized and why. It reflects the code as of -`1.1.0-beta.2`. +`1.1.0-beta.3`. ## Goals @@ -17,15 +17,19 @@ This document explains how MCHeli Rework is organized and why. It reflects the c ``` ┌────────────────────────────────────────────────────────────────┐ + │ forge / neoforge / fabric entry point, event wiring, packet │ loader module + │ channel, vehicle ingredient type, mixins │ (forge-26.3, ...) + ├────────────────────────────────────────────────────────────────┤ │ client renderer, mesh cache, runtime assets, crew │ client only │ controller, sound, HUD renderer, pack download │ - ├────────────────────────────────────────────────────────────────┤ platform module - │ entity AircraftEntity and one subclass per kind │ (forge-26.3) + ├────────────────────────────────────────────────────────────────┤ minecraft- + │ entity AircraftEntity and one subclass per kind │ (minecraft-26.3) │ item AircraftItem, VehicleIngredient │ - │ network PilotStatePacket, CrewActionPacket, PackSync │ Minecraft + Forge + │ network packets, ModNetwork, PackSync │ Minecraft, no loader │ particle GlowParticleOptions │ │ registry entities, items, components, particles, tabs │ - │ (root) MCHeli, configs, McheliCommand, ContentPacks │ + │ platform Platform, Registration │ + │ (root) MCHeli, CommonHooks, configs, McheliCommand │ ├────────────────────────────────────────────────────────────────┤ │ content Definition, DefinitionReader, ContentRegistry, │ common module │ aircraft/* definitions, recipe conversion, │ @@ -34,46 +38,98 @@ This document explains how MCHeli Rework is organized and why. It reflects the c │ hud HudScript, Expression, HudVariable │ │ physics AircraftRotation, GroundImpact │ │ effect NightGlow │ + │ config ConfigSpec (the TOML config format) │ └────────────────────────────────────────────────────────────────┘ ``` -Arrows only point down. The bottom half lives in the `common` Gradle module, which is compiled -and tested with nothing from Minecraft or Forge on its classpath (only the JDK, SLF4J, JSpecify -and JOML), so a stray import fails the build; `CommonBoundaryTest` also scans the sources. The top -half is a platform module, one per Minecraft version, which compiles `common`'s sources together -with its own into one jar. Ports to older versions (phase 7) add platform modules and leave -`common` alone; see [DEVELOPMENT.md](DEVELOPMENT.md#common-and-platform-modules). - -`client` is only referenced from `MCHeli` behind a `Dist.CLIENT` check, so a dedicated server never -loads a client class. - -Package root: `com.jnhprojects.mcheli`. Mod id and asset namespace: `mcheli`, the same as the -original, so existing resource paths and content packs keep working. +Arrows only point down. The bottom layer lives in the `common` Gradle module, which is compiled +and tested with nothing from Minecraft or a loader on its classpath (only the JDK, SLF4J, +JSpecify and JOML), so a stray import fails the build; `CommonBoundaryTest` also scans the +sources. The middle layer, `minecraft-`, is everything that touches Minecraft but no +loader. The top layer is a thin module per loader and Minecraft version, which compiles both +lower layers together with its own glue into one jar. See +[DEVELOPMENT.md](DEVELOPMENT.md#three-layers). + +The shared code reaches the loader in two ways only: + +- **`Platform`**, installed by the loader module before anything else runs: the game and config + folders, the mod version, whether this is a client, sending a packet, and a creative tab + builder (vanilla's is not public on Fabric, and only Forge and NeoForge order tabs). +- **`Registration`**, a list of registry entries (`Registered`, like Forge's + `RegistryObject`) that the shared registry classes declare during `MCHeli.init`. Forge and + NeoForge register them from their `RegisterEvent`; Fabric registers them straight away. + +Everything else goes the other way: the loader module calls `CommonHooks` (server start and stop, +ticks, player join and leave, commands, the imported pack) and `client.ClientHooks` (renderers, +keys, reload listeners, HUD layer, ticks, camera, hand, use and pick block keys, rider pose) from +its own events. Where a loader has no event, the loader module adds a mixin at the place Forge +fires its event, so the shared code sees the same thing on every loader: + +| Hook | Forge | NeoForge | Fabric | +|------|-------|----------|--------| +| Camera angles and cockpit eye | `ViewportEvent.ComputeCameraAngles` | mixin on `GameRenderer` (NeoForge's event fires before vanilla sets the camera position) | mixin on `GameRenderer`, plus roll on `Camera` | +| Hide the hand | `RenderHandEvent` | `RenderHandEvent` | mixin on `GameRenderer.renderItemInHand` | +| Use and pick block keys | `InputEvent.InteractionKeyMappingTriggered` | the same | mixin on `Minecraft` | +| Rider tilt | `RenderLivingEvent.Pre` | `RenderLivingEvent.Pre` | mixin on `LivingEntityRenderer.submit` | +| Imported content pack | `AddPackFindersEvent` | `AddPackFindersEvent` | mixin on the `PackRepository` constructor | +| Streamed sounds | `SoundInstance.getStream` | `SoundInstance.getStream` | mixin adding `getAudioStream` to `RuntimeSoundInstance` | + +NeoForge rolls the camera the other way round from Forge, so its mixin negates the roll. + +### Minecraft 1.21.1 + +`minecraft-1.21.1` is a copy of `minecraft-26.3` ported back, so the two read side by side. After +1.1.0 it is frozen (see [supported versions](ROADMAP.md#supported-versions)), so the copy is not +kept in step by hand beyond that. What differs: + +- **Rendering.** 1.21.1 has no render states or submit step. Each renderer fills the same state + object itself at the start of `render` and draws straight into the `MultiBufferSource`, so + `PartRenderer`, `MeshRenderer` and `GlassSplit` are unchanged apart from that. The glow uses a + render type built from `RenderType`'s public constructor (vanilla keeps `RenderType.create` + package private) and a custom `ParticleRenderType` for additive particles. +- **Vehicle icons.** There is no `item_model` component. The import writes one item model per kind + whose `custom_model_data` overrides pick each vehicle's model, and lists the numbers in + `mcheli/imported/item_icons.properties`; `AircraftItem.stackOf` puts the number on the stack. +- **Recipes** take ingredients as `{"item": ...}` and `{"tag": ...}` objects; the import writes + them that way for `ImportTarget.V1_21_1`. The marker file records the target, so a folder + imported for one version is imported again by the other. +- **Entities** extend `Entity` instead of `VehicleEntity`, whose abstract `getDropItem` is package + private in vanilla, and keep their own hurt wobble data. Save data is `CompoundTag`, damage goes + through `hurt` (server only), server position updates are eased in by `lerpTo` like vanilla's + boat, and a click on the hull arrives through `interactAt`. +- **Camera.** Forge 1.21.1 fires its camera event after the camera is placed, so Forge uses the + event. NeoForge and Fabric hook `GameRenderer.renderLevel` just before the camera rotation turns + into the view matrix, the spot Forge fires from. + +`client` is only referenced from the loader modules behind a physical client check, so a +dedicated server never loads a client class. + +Package root: `com.jnhprojects.mcheli`, with the loader glue in `.forge`, `.neoforge` and +`.fabric`. Mod id and asset namespace: `mcheli`, the same as the original, so existing resource +paths and content packs keep working. ## Startup ``` -MCHeli constructor - ├─ LegacyContent.prepare import the original zip into mcheli/imported - ├─ LegacyContent.registerPacks that folder as a required resource + data pack - ├─ ContentPacks.load(false) the import plus every pack in mcheli/packs - ├─ registerConfig(SERVER, ServerConfig.SPEC) serverconfig/mcheli-server.toml - ├─ registerConfig(CLIENT, ClientConfig.SPEC) config/mcheli-client.toml - ├─ ModDataComponents.register mcheli:vehicle - ├─ ModEntities.register one entity type per kind - ├─ ModParticles.register mcheli:glow - ├─ ModItems.register(content) one item per kind, plus the 1.0.0 items - ├─ ModCreativeTabs.register - ├─ ModRecipes.register mcheli:vehicle ingredient, recipe book - ├─ ModNetwork.register - ├─ PackSync.init pack list on join, downloads - ├─ McheliCommand.register /mcheli - └─ ClientSetup.init (client only) renderers, keys, reload listener, controller +loader entry point (McheliForge, McheliNeoForge, McheliFabric) + ├─ Platform.install + ├─ MCHeli.init + │ ├─ LegacyContent.prepare import the original zip into mcheli/imported + │ ├─ ContentPacks.load(false) the import plus every pack in mcheli/packs + │ ├─ ModDataComponents.init mcheli:vehicle + │ ├─ ModEntities.init one entity type per kind + │ ├─ ModParticles.init mcheli:glow + │ ├─ ModItems.init(content) one item per kind, plus the 1.0.0 items + │ └─ ModCreativeTabs.init + ├─ register Registration's entries, and the mcheli:vehicle ingredient type + ├─ register ModNetwork's packets + ├─ wire events to CommonHooks imported pack, server config, packs, commands + └─ client only: ClientHooks.init and client events client config, renderers, keys, HUD, input ``` -Definitions are still parsed in the constructor, but only because the 1.0.0 items have to be -registered before Forge freezes the registries (see [Vehicle items](#vehicle-items)); nothing else -depends on them existing that early any more. The files are read straight from the imported +Definitions are still parsed during mod construction, but only because the 1.0.0 items have to be +registered before the loader freezes the registries (see [Vehicle items](#vehicle-items)); nothing +else depends on them existing that early any more. The files are read straight from the imported folder and the content packs (see [Assets](#assets) and [Content packs](#content-packs)). Models and textures, by contrast, go through the resource manager, so resource packs can replace them. @@ -112,8 +168,8 @@ number moves, rebuilding seats, parts and weapons while keeping the ammunition i ### Vehicle items -1.0.0 registered one item per vehicle at startup, and Forge freezes the item registry before a -world loads, so a vehicle added later could never get an item. Since 1.1.0 there is one item per +1.0.0 registered one item per vehicle at startup, and the loaders freeze the item registry before +a world loads, so a vehicle added later could never get an item. Since 1.1.0 there is one item per kind (`mcheli:helicopter`, `mcheli:plane`, `mcheli:tank`, `mcheli:vehicle`). A stack names its vehicle in the `mcheli:vehicle` data component and points its `minecraft:item_model` component at the item model the import wrote for that vehicle, so it looks and reads as before. @@ -127,9 +183,12 @@ themselves with the generic stack (count, name and other components kept) as soo a player's inventory. A stack in a chest converts when a player takes it out. Recipes craft the generic item with both components set. A vehicle used as an ingredient (the -AH-6X recipe takes an AH-6) becomes a `{"type": "mcheli:vehicle", "vehicle": "ah-6"}` ingredient, -`VehicleIngredient`, registered as a Forge ingredient serializer, which only matches stacks of that -vehicle. +AH-6X recipe takes an AH-6) becomes a `{"type": "mcheli:vehicle", "vehicle": "ah-6"}` ingredient that only matches stacks of +that vehicle. Each loader names custom ingredients under its own key (`type` on Forge, +`neoforge:ingredient_type` on NeoForge, `fabric:type` on Fabric), so the import writes all three +and each loader ignores the other two; a world keeps working when it moves to another loader. +`VehicleIngredient` holds the matching, and each loader module wraps it in its own ingredient +class. ### Reloading content @@ -190,19 +249,25 @@ instead. `RuntimeAssets.setRoots` swaps the folder list in one write, the pack l ### Server config and command -`ServerConfig` is a Forge `SERVER` config, `serverconfig/mcheli-server.toml` in each world: -explosions breaking blocks and starting fires, a damage factor for vehicles, infinite ammunition, -and a list of disabled vehicles. Only the server applies these rules; the getters return the +`ServerConfig` is `serverconfig/mcheli-server.toml` in each world, read when the world starts and +on `/mcheli reload`: explosions breaking blocks and starting fires, a damage factor for vehicles, +infinite ammunition, and a list of disabled vehicles. Only the server applies these rules; the getters return the defaults (the 1.0.0 behaviour) when no world is loaded. `McheliCommand` registers `/mcheli` (content status), `/mcheli list [kind]`, and for operators `/mcheli give` and `/mcheli reload`. +Both configs use `config.ConfigSpec` from `common`, a small TOML reader and writer with the layout +Forge's config spec wrote, so files from 1.0.0 and the first betas keep their values on every +loader. A missing key or bad value falls back to its default, and the file is written back +complete with comments. Unlike Forge's, it does not watch the file: edits apply on the next world +start or `/mcheli reload`. + Recipes are data in 26.x, so the definitions' `AddRecipe` and `AddShapelessRecipe` lines are turned into `data/mcheli/recipe/.json` during the import by `content.recipe.LegacyRecipe`, following `MCH_ItemRecipe`. `LegacyItems` maps the 1.7.10 item names and damage values: dyes and wool by colour, the common `c:` tags where an item became a family (iron, glass, dyes) so other mods' materials count, vanilla tags for logs, planks and boats. As in 1.7.10, a pattern letter the key does not define is an empty slot. `RecipeDataTest` converts the whole set and spot checks -the output. `ModRecipes` puts every MCHeli recipe in a player's recipe book on login, standing in +the output. `CommonHooks.playerLoggedIn` puts every MCHeli recipe in a player's recipe book on login, standing in for the drafting table's recipe list. ## Models @@ -423,7 +488,7 @@ camera facing halos and tracer ribbons for `ProjectileRenderer` (rounds and rock particle that `ExplosionEffect` spawns for the flash, the embers and the lingering crash fire. How bright a glow gets comes from `effect.NightGlow` in `common`: the block light, sky light and the sky darkening at that spot, so it is faint at noon and full at night, in caves and under -water. `ClientConfig`, a Forge `CLIENT` config, turns it off per player. +water. `ClientConfig` (`config/mcheli-client.toml`) turns it off per player. **CrewController** also hides the first person hand through `RenderHandEvent` wherever the original did: for the pilot, and for anyone looking through a camera or with a weapon selected. @@ -504,16 +569,18 @@ of the content packs in use. ours so the texture manager takes over again. - **Sounds**: `RuntimeSoundInstance` (the engine loop extends it) plays through the sound manager when a resource pack defines the event, and otherwise streams the OGG file a runtime root holds - for it through Forge's `SoundInstance.getStream` hook, with no `sounds.json` entry and no reload. + for it through the loader's sound stream hook (`SoundInstance.getStream` on Forge and NeoForge, + `getAudioStream` on Fabric), with no `sounds.json` entry and no reload. -`LegacyContent` adds the folder through `AddPackFindersEvent` for both pack types, as a required +`CommonHooks.packSource` adds the folder (through `AddPackFindersEvent`, or a mixin on Fabric) for +both pack types, as a required pack at the bottom of the list: it is enabled in every world, cannot be turned off, and regular resource packs still override it. Minecraft merges lang files across packs, so the item names from the import and the rework's own keys in the jar's lang files sit side by side; `LegacyImporterTest` checks they never share a key. Without a zip or an earlier import, `ContentRegistry` is empty: the game starts with no vehicles -and `ClientSetup` tells the player in the chat where the zip goes. A zip that fails to import is +and `ClientHooks.loggedIn` tells the player in the chat where the zip goes. A zip that fails to import is logged and reported the same way. The repository holds none of the original files. The unit tests get them the way players do: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 36fa136..0a3cced 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,66 +4,97 @@ | Tool | Version | Notes | |------|---------|-------| -| JDK | 25 | Gradle provisions one through the Foojay resolver if none is installed. | -| Gradle | 9.7.1 | Use the wrapper (`./gradlew`). | -| ForgeGradle | 7.x | Sets up Minecraft through the Mavenizer on first build. | -| Forge | 66.1.0 for Minecraft 26.3 | Mappings are Mojang's official names. | - -The first build downloads and decompiles Minecraft, which takes a few minutes. Avoid running a -second Gradle build (for example an IDE import) while that is in progress: both write the same -cache and can corrupt each other's output. If setup fails with a zip or EOF error, wait for the -other process to finish and build again. +| JDK | 25 (and 21 for the 1.21.1 modules) | Gradle provisions them through the Foojay resolver if none is installed. | +| Gradle | 9.8.2 | Use the wrapper (`./gradlew`). Loom 1.19 needs 9.8. | +| ForgeGradle | 7.x | Forge modules. Sets up Minecraft through the Mavenizer on first build. | +| ModDevGradle | 2.0.x | NeoForge modules. | +| Fabric Loom | 1.19 | Fabric modules: `net.fabricmc.fabric-loom` for 26.x, `-remap` for 1.21.1. | +| Forge | 66.1.0 for Minecraft 26.3 | | +| NeoForge | 26.3.0.99 for Minecraft 26.3 | | +| Fabric | Loader 0.20.1, Fabric API 0.165.0+26.3 | | +| Forge | 52.1.20 for Minecraft 1.21.1 | Built with ForgeGradle 7 and the official mappings. | +| NeoForge | 21.1.265 for Minecraft 1.21.1 | | +| Fabric | Fabric API 0.116.17+1.21.1 | Loom remaps the jar, which is obfuscated on 1.21.1. | + +All three use Mojang's official names, so the shared code reads the same on every loader. + +The first build downloads and decompiles Minecraft once per loader, which takes a few minutes. +Avoid running a second Gradle build (for example an IDE import) while that is in progress: both +write the same caches and can corrupt each other's output. If setup fails with a zip or EOF error, +wait for the other process to finish and build again. ## Common tasks ``` -./gradlew build # compile and test every module, one jar per Minecraft version in build/release -./gradlew :common:test # unit tests (they all live in common) -./gradlew :forge-26.3:runClient # start a development client from ./run -./gradlew :forge-26.3:runServer # start a development dedicated server from ./run +./gradlew build # compile and test every module, one jar per loader and version in build/release +./gradlew :common:test # unit tests (they all live in common) +./gradlew :forge-26.3:runClient # development client from ./run +./gradlew :neoforge-26.3:runClient +./gradlew :fabric-26.3:runClient +./gradlew :neoforge-1.21.1:runClient # 1.21.1 modules run from ./run-1.21.1 +./gradlew ::runServer # development dedicated server from ./run ``` Shared properties live in the root `gradle.properties`: `mod_version`, the mod metadata, the list -of platform modules (`platforms`) and the library versions `common` compiles against. Each -platform module has its own `gradle.properties` with its Minecraft, loader and Java versions. -`mods.toml` and `pack.mcmeta` are filled in from them at build time. +of loader modules (`platforms`) and the library versions `common` compiles against. Each loader +module has its own `gradle.properties` with its loader, Minecraft and Java versions. +`mods.toml`, `neoforge.mods.toml`, `fabric.mod.json` and `pack.mcmeta` are filled in from them +at build time. What every loader module shares (source folders, jar contents, manifest, file +names) is in `gradle/loader.gradle`. ## Repository layout ``` common/ version-free code, built and tested without Minecraft - src/main/java content, model, hud, physics (see ARCHITECTURE.md) + src/main/java content, model, hud, physics, config (see ARCHITECTURE.md) src/main/resources lang files shared by every platform src/test/java all unit tests, including the boundary test -forge-26.3/ the Minecraft 26.3 / Forge 66 platform module - src/main/java rendering, networking, registration, entities, events - src/main/resources mods.toml, pack.mcmeta, item models, damage type data - gradle.properties Minecraft, Forge and Java versions for this module +minecraft-26.3/ code shared by every loader on Minecraft 26.3 (not a Gradle project) + src/main/java entities, rendering, networking, registries, HUD, sounds, hooks + src/main/resources pack.mcmeta, item models, damage type data, mod icon +forge-26.3/ Forge 66 glue: entry point, events, payload channel, ingredient +neoforge-26.3/ NeoForge glue, plus a camera mixin +fabric-26.3/ Fabric glue, plus mixins for what Fabric API has no event for +minecraft-1.21.1/ the same shared code, backported to Minecraft 1.21.1 +forge-1.21.1/, neoforge-1.21.1/, fabric-1.21.1/ the loader glue for 1.21.1 +gradle/loader.gradle what every loader module shares docs/ documentation and content format guides .github/workflows CI and release pipelines ``` -### Common and platform modules +### Three layers -1.1.0 brings older Minecraft versions, so the code is split in two: +1.1.0 runs on several Minecraft versions and three loaders, so the code is split in three: - **`common`** holds everything that does not depend on the game version: definition parsing, the content registry, model loading, the HUD expression language, recipe conversion, rotation - and impact maths. Its classpath has only the JDK, SLF4J, JSpecify and JOML (all shipped by every - supported Minecraft version), so a stray Minecraft import fails to compile, and - `CommonBoundaryTest` names the offending line. -- **A platform module per Minecraft version** (`forge-26.3` today) holds the glue: entities, - rendering, networking, registration, config, commands. It compiles `common`'s sources together - with its own, so each version ships as one jar and runs as one module in development. - -A feature that is pure logic goes in `common` with a unit test; only the part that touches -Minecraft goes in the platform module. Adding a version means adding a module directory, listing -it in `platforms`, and porting the platform code; `common` should not need to change. The run -folder `./run` is shared for now; later modules will get their own. - -IDEs import the project as three Gradle projects (root, `common`, `forge-26.3`). After pulling the -split, re-import the Gradle project so the IDE picks up the new source folders and run -configurations; the old root-level `run*.launch` files point at a project that no longer exists. + and impact maths, the config file format. Its classpath has only the JDK, SLF4J, JSpecify and + JOML (all shipped by every supported Minecraft version), so a stray Minecraft import fails to + compile, and `CommonBoundaryTest` names the offending line. +- **`minecraft-`** holds everything that touches Minecraft but not a loader: entities, + rendering, networking, the HUD, sounds, commands, config. It talks to the loader through + `platform.Platform` (paths, sending packets, creative tab builders) and `platform.Registration` + (registry entries), and exposes `CommonHooks` and `client.ClientHooks` for the loader to call + from its events. It is not a Gradle project of its own: every loader module on that version + compiles it, and since Fabric has none of Forge's classes and Forge none of Fabric's, a loader + API that slips in breaks a build. +- **A loader module per loader and version** (`forge-26.3`, `neoforge-26.3`, `fabric-26.3`) is + the thin glue: the entry point, registering what `Registration` collected, wiring events to the + hooks, the packet channel, the vehicle ingredient type, and mixins where the loader has no event + (camera roll, hand, use and pick block keys, rider pose, the imported content pack on Fabric). + +Each loader module compiles `common` and its `minecraft-` sources together with its own, +so each loader and version ships as one jar and runs as one module in development. + +A feature that is pure logic goes in `common` with a unit test; the part that touches Minecraft +goes in `minecraft-`; only what needs a loader API goes in the loader modules. The run +folder `./run` is shared by every 26.3 module and `./run-1.21.1` by every 1.21.1 module; worlds made by +one loader open on the others, but never open a 26.3 world on 1.21.1. + +IDEs import the project as one Gradle project per module. A file under `minecraft-26.3` belongs +to all three loader modules, so the IDE shows it under whichever it picked; it compiles under all +of them on the command line. After pulling the split, re-import the Gradle project; the old +`forge-26.3` run configurations still work, the NeoForge and Fabric ones are new. `Mod/` holds the original `MC1.7.10_mcheli_1.0.3.zip`. It is git-ignored. Download it from the [MCHeli CurseForge page](https://www.curseforge.com/minecraft/mc-mods/mcheli-minecraft-helicopter-mod/files) @@ -105,8 +136,9 @@ Releases are cut from tags. The release workflow refuses to publish when the tag `.github/workflows/release.yml` then builds and tests the tag, extracts the matching `CHANGELOG.md` section as release notes, and publishes a GitHub release with one jar per -Minecraft version (`mcheli--.jar`, gathered by the `releaseJars` task into -`build/release`). One tag always releases every platform module at the same mod version. Versions +loader and Minecraft version (`mcheli---.jar`, such as +`mcheli-fabric-26.3-1.1.0-beta.3.jar`, gathered by the `releaseJars` task into `build/release`). +One tag always releases every loader module at the same mod version. Versions containing a hyphen (`-alpha.N`, `-beta.N`, `-rc.N`) are marked as pre-releases. Every release, pre-releases included, is mirrored to the public repository and tagged there too. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b532c4a..6cb6f24 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,7 +12,7 @@ tagged pre-release that installs and runs on its own. | 3 | `1.0.0-alpha.3` | Weapons and combat | Implemented, in-game test in progress | | 4 | `1.0.0-beta.1` | HUD and polish | Done | | | `1.0.0` | Release: code-only jar | Released | -| 5-10 | `1.1.0-beta.1` to `beta.6` | Content packs, older versions, equipment, workshop ([plan](#road-to-110)) | Planned | +| 5-11 | `1.1.0-beta.1` to `beta.7` | Content packs, NeoForge and Fabric, 1.21.1 and 1.20.1, equipment, workshop ([plan](#road-to-110)) | In progress | | | `1.1.0` | Release on CurseForge | Planned | ## Phase 1: foundation and flyable helicopters @@ -84,7 +84,7 @@ textures (back faces are culled again). See the [test plan](TESTING.md#phase-4-c the recipe book from the first login - [x] Code-only jar: the original content is imported from the player's own copy of the 1.0.3 zip -The rest of the original phase 4 list moved to 1.1.0 (phase 8 below), so 1.0.0 can ship. +The rest of the original phase 4 list moved to 1.1.0 (phase 9 below), so 1.0.0 can ship. ## 1.0.0 release criteria @@ -95,9 +95,9 @@ The rest of the original phase 4 list moved to 1.1.0 (phase 8 below), so 1.0.0 c ## Road to 1.1.0 -1.1.0 adds three big things on top of 1.0.0: content packs, older Minecraft versions, and the -vehicle workshop. It also picks up the phase 4 items that were left out of 1.0.0. The work is -split into six phases, each ending in a beta. +1.1.0 adds three big things on top of 1.0.0: content packs, more Minecraft versions and loaders, +and the vehicle workshop. It also picks up the phase 4 items that were left out of 1.0.0. The work is +split into seven phases, each ending in a beta. ### Release channels @@ -133,17 +133,37 @@ Rules that hold for every beta: |-------|---------|-------|-------| | 5 | `1.1.0-beta.1` | Groundwork: runtime registry, config, multi-version build | Released | | 6 | `1.1.0-beta.2` | Content packs and multiplayer sync | Released | -| 7 | `1.1.0-beta.3` | Older Minecraft versions | Planned | -| 8 | `1.1.0-beta.4` | Equipment and the rest of phase 4 | Planned | -| 9 | `1.1.0-beta.5` | Workshop: model, parts and tuning | Planned | -| 10 | `1.1.0-beta.6` | Workshop: textures, sounds and sharing | Planned | +| 7 | `1.1.0-beta.3` | Loaders (Forge, NeoForge, Fabric) and 1.21.1 | Released | +| 8 | `1.1.0-beta.4` | 1.20.1 | Planned | +| 9 | `1.1.0-beta.5` | Equipment and the rest of phase 4 | Planned | +| 10 | `1.1.0-beta.6` | Workshop: model, parts and tuning | Planned | +| 11 | `1.1.0-beta.7` | Workshop: textures, sounds and sharing | Planned | | | `1.1.0` | Release on CurseForge | Planned | The order is deliberate. The registry and the source split come first because everything else sits on them. Content packs come before the workshop, since the workshop's output is a pack. The ports come right after packs, while the code is still close to 1.0.0; from then on each -feature is built once in shared code and only its Minecraft glue is written per version, instead -of back-porting four phases of work at the end. +feature is built once in shared code and only its Minecraft glue is written per version and +loader, instead of back-porting several phases of work at the end. + +### Supported versions + +MCHeli stopped at Minecraft 1.7.10. 1.1.0 is the bridge back: it runs on the versions most +players and modpacks are on today, so anyone can pick the mod up again, and then follows the +latest Minecraft. + +| Minecraft | Also runs on | Java | Forge | NeoForge | Fabric | +|-----------|--------------|------|-------|----------|--------| +| 26.3 | | 25 | yes | yes | yes | +| 1.21.1 | 1.21 | 21 | yes | yes | yes | +| 1.20.1 | 1.20 | 17 | yes | the Forge jar (NeoForge 1.20.1 is a Forge fork) | yes | + +Each major version gets its most played minor rather than its last one (1.20.6 and 1.21.11 have +few players). Nothing below 1.20. + +**After 1.1.0, the 1.20.1 and 1.21.1 builds are frozen.** They get no further fixes or features; +development continues on the latest Minecraft version only, and players are expected to move +up with it. ## Phase 5: groundwork (`1.1.0-beta.1`) @@ -184,24 +204,41 @@ The external content packs from phase 4, in the package format the workshop will - [x] Night glow: tracers, rocket motors, flares and explosions glow, faint by day and bright at night; a crash keeps burning with a glow and the wreck burns for 30 seconds. Client config `effects.nightGlow` turns it off. -## Phase 7: older Minecraft versions (`1.1.0-beta.3`) - -Ports of the platform module. The shared code should need no changes; if it does, the split -from phase 5 gets fixed rather than worked around. - -- [ ] Decide the target list and loader for each version (see [open decisions](#open-decisions)) -- [ ] 26.2 and 26.1 on Forge: closest to 26.3, mostly renamed methods -- [ ] 1.21.1 on Java 21: older rendering and networking APIs, item stacks with NBT where 26.x - uses data components -- [ ] Legacy importer checked on each version (pack format numbers and the asset layout differ) -- [ ] CI builds and tests every module on every pull request -- [ ] [TESTING.md](TESTING.md) gets a short per-version smoke test: import, fly a helicopter, - drive a tank, fire a missile, join a dedicated server - -1.20.1 (Java 17) is the next candidate after 1.21.1. It joins 1.1.0 only if it is ready in this -phase; otherwise it moves to 1.2.0 so it does not hold the release back. - -## Phase 8: equipment and the rest of phase 4 (`1.1.0-beta.4`) +## Phase 7: loaders and 1.21.1 (`1.1.0-beta.3`) + +1.1.0 runs on three Minecraft versions, each on three loaders (see +[supported versions](#supported-versions)). This phase splits the code so the loaders share +almost everything, adds NeoForge and Fabric on 26.3, then brings all three to 1.21.1. + +- [x] Three layers: `common` (pure Java, as now), shared code per Minecraft version (rendering, + entities, sounds, HUD, which are the same on every loader), and a thin module per loader + (registration, events, networking, config) +- [x] NeoForge and Fabric on 26.3, with the same features and checklists as Forge +- [x] 1.21.1 (Java 21) on Forge, NeoForge and Fabric: older rendering (no render states), older + networking, item models from the item JSON instead of `item_model` +- [ ] Content packs and their sync work across loaders on the same Minecraft version (a Fabric + client on a NeoForge server) +- [x] Legacy importer checked on 1.21.1 (pack format numbers and the asset layout differ) +- [x] Jar names carry the loader and the Minecraft version, such as + `mcheli-fabric-1.21.1-1.1.0-beta.3.jar`; the release workflow publishes every jar +- [x] CI builds and tests every module on every pull request +- [x] [TESTING.md](TESTING.md) gets a short smoke test per jar: import, fly a helicopter, drive a + tank, fire a missile, join a dedicated server + +## Phase 8: 1.20.1 (`1.1.0-beta.4`) + +The largest modpack version, and the furthest from 26.3. + +- [ ] `common` compiles for Java 17 (no pattern matching `switch`, no `SequencedCollection` + methods) +- [ ] 1.20.1 on Forge and Fabric. NeoForge's 1.20.1 is a fork of Forge 47, so the Forge jar is + tested there instead of built twice +- [ ] Vehicle items keep their vehicle in NBT, since data components only arrived in 1.20.5 +- [ ] Rendering, networking and registration for 1.20.1 +- [ ] Legacy importer and content packs checked on 1.20.1 +- [ ] Same smoke tests as phase 7, for every 1.20.1 jar + +## Phase 9: equipment and the rest of phase 4 (`1.1.0-beta.5`) Gameplay carried over from phase 4, built once in `common` with per-version glue. @@ -219,7 +256,7 @@ Gameplay carried over from phase 4, built once in `common` with per-version glue - [ ] The drafting table and its recipe browser (pack vehicles included) - [ ] Multiplayer team manager and spotting -## Phase 9: workshop, model, parts and tuning (`1.1.0-beta.5`) +## Phase 10: workshop, model, parts and tuning (`1.1.0-beta.6`) The first half of the [editor](#the-editor). Its output is a pack from phase 6, so anything built here syncs to other players with no extra work. @@ -232,7 +269,7 @@ built here syncs to other players with no extra work. - [ ] Save, export and load packages; undo and redo - [ ] Workshop guide in `docs/content/workshop.md` -## Phase 10: workshop, textures, sounds and sharing (`1.1.0-beta.6`) +## Phase 11: workshop, textures, sounds and sharing (`1.1.0-beta.7`) - [ ] Texture painting on the model and PNG import per part (cockpit textures included), with live preview @@ -242,7 +279,7 @@ built here syncs to other players with no extra work. ## 1.1.0 release -No new features after beta.6; a `1.1.0-rc.N` follows only if beta.6 testing finds problems. +No new features after beta.7; a `1.1.0-rc.N` follows only if beta.7 testing finds problems. - [ ] Every phase checklist done on every supported Minecraft version - [ ] A 1.0.0 world and the beta worlds load with their vehicles and items intact @@ -252,9 +289,6 @@ No new features after beta.6; a `1.1.0-rc.N` follows only if beta.6 testing find ## Open decisions -- **Older versions and loaders.** Which versions are in 1.1.0, and Forge or NeoForge for each - one below 26.x. NeoForge is where most 1.21.1 mods live; Forge keeps one loader for the whole - project. The current plan is 26.2, 26.1 and 1.21.1, with 1.20.1 next. - **Workshop entry point.** A new workshop block, or a tab in the drafting table. - **Pack hosting.** Whether there is ever a shared place to publish packs, or only hand sharing and server sync. diff --git a/docs/TESTING.md b/docs/TESTING.md index a2e0d64..e760518 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -5,13 +5,57 @@ hand with this plan before a pre-release is tagged. ## Setup -1. `./gradlew build`, then copy `build/release/mcheli-26.3-.jar` into the `mods` folder of - the Forge 26.3 profile and `MC1.7.10_mcheli_1.0.3.zip` into its `mcheli` folder. - Alternatively run `./gradlew :forge-26.3:runClient`, which imports the zip from `Mod/`. +1. `./gradlew build`, then copy the jar for the loader under test from `build/release` + (`mcheli---.jar`) into the `mods` folder of a profile for that loader + and Minecraft version, + and `MC1.7.10_mcheli_1.0.3.zip` into its `mcheli` folder. Fabric also needs Fabric API. + Alternatively run `./gradlew :-:runClient`, which imports the zip from `Mod/`. 2. Use a launcher profile with its own game directory. Mods for other loaders or Minecraft - versions in the same `mods` folder will prevent Forge from starting. + versions in the same `mods` folder will prevent the game from starting. 3. Create a creative world with cheats enabled. +The phase checklists below were written on Forge and are run in full there. Every other jar gets +the smoke test, which touches each piece of loader glue once. + +## Smoke test (every jar) + +Run this for each jar in `build/release`: Forge, NeoForge and Fabric on every Minecraft version. + +- [ ] The game reaches the title screen. The log has `MCHeli Rework on ` and no + `mcheli` errors; on the first start it also has `Imported ... files` from the zip. +- [ ] The four MCHeli creative tabs list the vehicles with icons and names, after the vanilla tabs. +- [ ] Fly a helicopter (`ah-64`): it takes off, banks and lands. In first person the view rolls + with the hull and the eye sits in the cockpit; the hand is hidden in the pilot seat. +- [ ] Other players and the rider model tilt with the hull (second player or F5). +- [ ] Drive a tank (`m1a2`): it moves, the turret follows the view, the gun fires. +- [ ] Fire a missile from a helicopter or jet: the use key fires without swinging the arm, the pick + block key switches weapons, the weapon HUD draws, the missile hits and explodes. +- [ ] Right-clicking the hull outside the main hit box boards the vehicle (the arm swings). +- [ ] Engine and rotor sounds play, including a content pack sound (the example pack). +- [ ] `config/mcheli-client.toml` exists; setting `nightGlow = false` and restarting removes the + glow. +- [ ] Join a dedicated server running the same jar: the server log has no `mcheli` errors, the + client downloads the server's content packs, `/mcheli status` and `/mcheli reload` work, + `serverconfig/mcheli-server.toml` is in the world folder. +- [ ] Crafting: an AH-6X can be crafted from an AH-6 and redstone (the vehicle ingredient), and + the recipe book lists MCHeli recipes. + +### Across loaders + +- [ ] A world made on Forge 26.3 with vehicles in it opens on NeoForge and Fabric 26.3 with the + vehicles and items intact, and back. +- [ ] A Fabric client joins a NeoForge server (both with the mod) and downloads its packs. +- [ ] A `serverconfig/mcheli-server.toml` written by 1.1.0-beta.2 (Forge) keeps its values. + +### 1.21.1 only + +- [ ] Vehicle icons show in the creative tabs and the hand (the kind model picks them by + `custom_model_data`); a pack vehicle shows the barrier. +- [ ] `mcheli/imported/item_icons.properties` exists, and the AH-6X recipe crafts an AH-6X with + its icon. +- [ ] Glow halos, tracers and the glow particle are additive and do not hide smoke behind them. +- [ ] A vehicle hit by a weapon wobbles (hurt time) and takes damage only once per hit. + ## Phase 1 checklist ### Loading diff --git a/docs/content/modeling.md b/docs/content/modeling.md index 4c655bf..9ff67dd 100644 --- a/docs/content/modeling.md +++ b/docs/content/modeling.md @@ -3,7 +3,7 @@ This guide covers the model side of a new vehicle: the part you build outside Minecraft. The vehicle itself (seats, rotors, wings, engine, weapons, flight values) is described by a definition file, see [aircraft.md](aircraft.md). The vehicle workshop planned for 1.1.0 (see -[ROADMAP.md](../ROADMAP.md), phases 9 and 10) will let you do that second half in game by clicking on the +[ROADMAP.md](../ROADMAP.md), phases 10 and 11) will let you do that second half in game by clicking on the model instead of typing coordinates. The model rules below are the same either way, so a model made today keeps working in the workshop. diff --git a/fabric-1.21.1/build.gradle b/fabric-1.21.1/build.gradle new file mode 100644 index 0000000..beeda80 --- /dev/null +++ b/fabric-1.21.1/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java' + id 'idea' + id 'eclipse' + id 'net.fabricmc.fabric-loom-remap' +} + +apply from: rootProject.file('gradle/loader.gradle') + +// The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this +// one (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +loom { + runs { + configureEach { + runDir = "../run-${minecraft_version}" + vmArg "-Dmcheli.legacyZip=${legacyZip}" + } + server { + programArgs '--nogui' + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${minecraft_version}" + // 1.21.1 is obfuscated; Mojang's names keep the shared code the same as on Forge and NeoForge. + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${fabric_loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}" +} diff --git a/fabric-1.21.1/gradle.properties b/fabric-1.21.1/gradle.properties new file mode 100644 index 0000000..edfae98 --- /dev/null +++ b/fabric-1.21.1/gradle.properties @@ -0,0 +1,8 @@ +# Minecraft 1.21.1 on Fabric +loader=fabric +minecraft_version=1.21.1 +minecraft_version_range=~1.21.1 +fabric_loader_version=0.20.1 +loader_version_range=>=0.16.0 +fabric_api_version=0.116.17+1.21.1 +java_version=21 diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 0000000..a7114f1 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java @@ -0,0 +1,59 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.fabricmc.api.EnvType; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.itemgroup.v1.FabricItemGroup; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; + +import java.nio.file.Path; + +final class FabricPlatform implements Platform { + @Override + public String loaderName() { + return "fabric"; + } + + @Override + public Path gameDir() { + return FabricLoader.getInstance().getGameDir(); + } + + @Override + public Path configDir() { + return FabricLoader.getInstance().getConfigDir(); + } + + @Override + public String modVersion() { + return FabricLoader.getInstance().getModContainer(MCHeli.MOD_ID) + .map(c -> c.getMetadata().getVersion().getFriendlyString()).orElse(""); + } + + @Override + public boolean isClient() { + return FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT; + } + + @Override + public void sendToServer(CustomPacketPayload payload) { + ClientPlayNetworking.send(payload); + } + + @Override + public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { + ServerPlayNetworking.send(player, payload); + } + + /** Fabric orders tabs by registration, which already puts ours one after another. */ + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return FabricItemGroup.builder(); + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 0000000..5ad9f82 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.mojang.serialization.MapCodec; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredient; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredientSerializer; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +import java.util.List; + +/** {@link VehicleIngredient} as a Fabric ingredient, read from the {@code fabric:type} key. */ +record FabricVehicleIngredient(String vehicle) implements CustomIngredient { + static final CustomIngredientSerializer SERIALIZER = new CustomIngredientSerializer<>() { + private final MapCodec codec = + VehicleIngredient.codec(FabricVehicleIngredient::new, FabricVehicleIngredient::vehicle); + private final StreamCodec streamCodec = + VehicleIngredient.streamCodec(FabricVehicleIngredient::new, FabricVehicleIngredient::vehicle); + + @Override + public ResourceLocation getIdentifier() { + return VehicleIngredient.ID; + } + + @Override + public MapCodec getCodec(boolean allowEmpty) { + return codec; + } + + @Override + public StreamCodec getPacketCodec() { + return streamCodec; + } + }; + + @Override + public boolean test(ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public List getMatchingStacks() { + return VehicleIngredient.displayStacks(vehicle).toList(); + } + + @Override + public boolean requiresTesting() { + return true; + } + + @Override + public CustomIngredientSerializer getSerializer() { + return SERIALIZER; + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java new file mode 100644 index 0000000..382aacc --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.CommonHooks; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registration; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredientSerializer; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; + +/** + * The Fabric entry point: everything here only connects Fabric to the shared code. The imported + * content pack is added by {@code PackRepositoryMixin}, since Fabric has no pack finder event. + */ +public final class McheliFabric implements ModInitializer { + + @Override + public void onInitialize() { + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + + ServerLifecycleEvents.SERVER_STARTING.register(CommonHooks::serverAboutToStart); + ServerLifecycleEvents.SERVER_STOPPED.register(server -> CommonHooks.serverStopped()); + ServerTickEvents.END_SERVER_TICK.register(CommonHooks::serverTickEnd); + CommandRegistrationCallback.EVENT.register((dispatcher, context, selection) -> CommonHooks.registerCommands(dispatcher)); + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> CommonHooks.playerLoggedIn(handler.getPlayer())); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> CommonHooks.playerLoggedOut(handler.getPlayer())); + } + + @SuppressWarnings("unchecked") + private static void register(ResourceKey> key) { + Registry registry = (Registry) BuiltInRegistries.REGISTRY.get(key.location()); + if (registry == null) { + throw new IllegalStateException("No registry " + key.location()); + } + Registration.registerAll(key, (id, value) -> Registry.register(registry, id, value)); + } + + /** Client handlers are registered by {@link McheliFabricClient}; handlers run on the main thread. */ + private static void registerPackets() { + for (ModNetwork.Serverbound packet : ModNetwork.SERVERBOUND) { + serverbound(packet); + } + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + clientbound(packet); + } + } + + private static void clientbound(ModNetwork.Clientbound packet) { + PayloadTypeRegistry.playS2C().register(packet.type(), packet.codec()); + } + + private static void serverbound(ModNetwork.Serverbound packet) { + PayloadTypeRegistry.playC2S().register(packet.type(), packet.codec()); + ServerPlayNetworking.registerGlobalReceiver(packet.type(), (msg, context) -> packet.handler().accept(msg, context.player())); + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 0000000..859952f --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,69 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.particle.v1.ParticleFactoryRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +import net.fabricmc.fabric.api.resource.IdentifiableResourceReloadListener; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.PreparableReloadListener; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.profiling.ProfilerFiller; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +/** + * Client side of the Fabric entry point. The camera, hand, key and rider hooks are mixins (see + * {@code com.jnhprojects.mcheli.fabric.mixin}), since Fabric API has no events for them. + */ +public final class McheliFabricClient implements ClientModInitializer { + + @Override + public void onInitializeClient() { + ClientHooks.init(); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ParticleFactoryRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ClientHooks.keyMappings().forEach(KeyBindingHelper::registerKeyBinding); + ResourceManagerHelper resources = ResourceManagerHelper.get(PackType.CLIENT_RESOURCES); + ClientHooks.reloadListeners().forEach((id, listener) -> resources.registerReloadListener(new Identified(id, listener))); + // Drawn after the vanilla HUD, which is where Forge's post sleep layer ends up too. + HudRenderCallback.EVENT.register(ClientHooks::drawHud); + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + clientbound(packet); + } + + ClientTickEvents.END_CLIENT_TICK.register(client -> ClientHooks.clientTickEnd()); + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> ClientHooks.loggedIn(client.player)); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> ClientHooks.loggedOut()); + } + + private static void clientbound(ModNetwork.Clientbound packet) { + ClientPlayNetworking.registerGlobalReceiver(packet.type(), (msg, context) -> packet.handler().accept(msg)); + } + + /** Fabric wants an id on every reload listener. */ + private record Identified(ResourceLocation id, PreparableReloadListener listener) implements IdentifiableResourceReloadListener { + @Override + public ResourceLocation getFabricId() { + return id; + } + + @Override + public CompletableFuture reload(PreparationBarrier barrier, ResourceManager manager, ProfilerFiller preparations, + ProfilerFiller reload, Executor background, Executor main) { + return listener.reload(barrier, manager, preparations, reload, background, main); + } + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 0000000..62bf841 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.Camera; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +/** + * Vanilla's camera has no roll. This is {@code Camera#setRotation} with the roll Forge patches in, + * so a banked aircraft tilts the view the same on every loader. + */ +@Mixin(Camera.class) +abstract class CameraMixin implements RollableCamera { + @Shadow + @Final + private static Vector3f FORWARDS; + @Shadow + @Final + private static Vector3f UP; + @Shadow + @Final + private static Vector3f LEFT; + + @Shadow + private float xRot; + @Shadow + private float yRot; + @Shadow + @Final + private Vector3f forwards; + @Shadow + @Final + private Vector3f up; + @Shadow + @Final + private Vector3f left; + @Shadow + @Final + private Quaternionf rotation; + + @Override + public void mcheli$setRotation(float yRot, float xRot, float roll) { + this.xRot = xRot; + this.yRot = yRot; + this.rotation.rotationYXZ((float) Math.PI - yRot * (float) (Math.PI / 180.0), -xRot * (float) (Math.PI / 180.0), + roll * (float) (Math.PI / 180.0)); + FORWARDS.rotate(this.rotation, this.forwards); + UP.rotate(this.rotation, this.up); + LEFT.rotate(this.rotation, this.left); + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 0000000..faba98b --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,40 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * The camera hook, where Forge fires its camera angle event on 1.21.1 (the camera is placed for + * the frame, and its rotation is about to become the view matrix), and the first person hand, + * which is hidden in some seats. + */ +@Mixin(GameRenderer.class) +abstract class GameRendererMixin { + @Shadow + @Final + private Camera mainCamera; + + @Inject(method = "renderLevel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;rotation()Lorg/joml/Quaternionf;", + ordinal = 0)) + private void mcheli$beforeViewRotation(CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, mainCamera.getPartialTickTime(), GameRendererMixin::setRotation); + } + + @Inject(method = "renderItemInHand", at = @At("HEAD"), cancellable = true) + private void mcheli$renderItemInHand(CallbackInfo ci) { + if (ClientHooks.hidesHand()) { + ci.cancel(); + } + } + + private static void setRotation(Camera camera, float yaw, float pitch, float roll) { + ((RollableCamera) camera).mcheli$setRotation(yaw, pitch, roll); + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 0000000..2cd967b --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java @@ -0,0 +1,23 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.world.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Tilts riders with their vehicle, where Forge fires its living entity render event. */ +@Mixin(LivingEntityRenderer.class) +abstract class LivingEntityRendererMixin { + + @Inject(method = "render(Lnet/minecraft/world/entity/LivingEntity;FFLcom/mojang/blaze3d/vertex/PoseStack;" + + "Lnet/minecraft/client/renderer/MultiBufferSource;I)V", at = @At("HEAD")) + private void mcheli$beforeRender(LivingEntity entity, float yaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, + int light, CallbackInfo ci) { + ClientHooks.beforeRenderLiving(entity, pose, partialTicks); + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 0000000..b4685f8 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.phys.HitResult; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** The use and pick block keys, which Forge and NeoForge let mods take through an input event. */ +@Mixin(Minecraft.class) +abstract class MinecraftMixin { + @Shadow + public @Nullable LocalPlayer player; + @Shadow + public @Nullable HitResult hitResult; + @Shadow + private int rightClickDelay; + + @Inject(method = "startUseItem", at = @At("HEAD"), cancellable = true) + private void mcheli$startUseItem(CallbackInfo ci) { + if (player == null) { + return; + } + ClientHooks.UseKey result = ClientHooks.onUseKey(InteractionHand.MAIN_HAND); + if (result != ClientHooks.UseKey.PASS) { + // Vanilla sets the same delay before it looks at the hands. + rightClickDelay = 4; + if (result == ClientHooks.UseKey.CONSUME_AND_SWING) { + player.swing(InteractionHand.MAIN_HAND); + } + ci.cancel(); + } + } + + @Inject(method = "pickBlock", at = @At("HEAD"), cancellable = true) + private void mcheli$pickBlock(CallbackInfo ci) { + if (hitResult != null && hitResult.getType() != HitResult.Type.MISS && ClientHooks.onPickBlockKey()) { + ci.cancel(); + } + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 0000000..965719b --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java @@ -0,0 +1,25 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.CommonHooks; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.server.packs.repository.RepositorySource; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import java.util.Arrays; + +/** + * Adds the imported original content to every pack repository, client resources and server data + * alike, which is what Forge and NeoForge do through their pack finder event. + */ +@Mixin(PackRepository.class) +abstract class PackRepositoryMixin { + + @ModifyVariable(method = "", at = @At("HEAD"), argsOnly = true) + private static RepositorySource[] mcheli$addImportedContent(RepositorySource[] sources) { + RepositorySource[] withOurs = Arrays.copyOf(sources, sources.length + 1); + withOurs[sources.length] = CommonHooks.packSource(); + return withOurs; + } +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java new file mode 100644 index 0000000..f31feae --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +/** Implemented on {@code Camera} by {@link CameraMixin}. */ +public interface RollableCamera { + /** Like vanilla's {@code setRotation}, with a roll in degrees as Forge applies it. */ + void mcheli$setRotation(float yRot, float xRot, float roll); +} diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 0000000..35f4032 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java @@ -0,0 +1,20 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.sound.RuntimeSoundInstance; +import net.fabricmc.fabric.api.client.sound.v1.FabricSoundInstance; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; + +import java.util.concurrent.CompletableFuture; + +/** Streams content pack sounds from disk through Fabric's sound instance hook. */ +@Mixin(RuntimeSoundInstance.class) +abstract class RuntimeSoundInstanceMixin implements FabricSoundInstance { + + @Override + public CompletableFuture getAudioStream(SoundBufferLibrary loader, ResourceLocation id, boolean repeatInstantly) { + return ((RuntimeSoundInstance) (Object) this).openStream(loader, id, repeatInstantly); + } +} diff --git a/fabric-1.21.1/src/main/resources/fabric.mod.json b/fabric-1.21.1/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..8346f4c --- /dev/null +++ b/fabric-1.21.1/src/main/resources/fabric.mod.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${mod_version}", + "name": "${mod_name}", + "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. A ground-up rework of MCHeli 1.0.3 by EMB4, which stopped at Minecraft 1.7.10. The vehicles come from your own copy of the original: put MC1.7.10_mcheli_1.0.3.zip from the MCHeli CurseForge page into the \"mcheli\" folder of the game directory.", + "authors": ["JayMar921 (rework)", "EMB4 (original author)", "Fureniku (CurseForge maintainer)"], + "contact": { + "homepage": "https://github.com/JnH-Projects/MCHeli-Rework-Forge", + "issues": "https://github.com/JnH-Projects/MCHeli-Rework-Forge/issues", + "sources": "https://github.com/JnH-Projects/MCHeli-Rework-Forge" + }, + "license": "LicenseRef-MCHeli-Rework-1.0", + "icon": "mcheli_logo.png", + "environment": "*", + "entrypoints": { + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] + }, + "mixins": ["mcheli.mixins.json"], + "depends": { + "fabricloader": "${loader_version_range}", + "fabric-api": "*", + "minecraft": "${minecraft_version_range}", + "java": ">=${java_version}" + } +} diff --git a/fabric-1.21.1/src/main/resources/mcheli.mixins.json b/fabric-1.21.1/src/main/resources/mcheli.mixins.json new file mode 100644 index 0000000..1b14b27 --- /dev/null +++ b/fabric-1.21.1/src/main/resources/mcheli.mixins.json @@ -0,0 +1,19 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/fabric-26.3/build.gradle b/fabric-26.3/build.gradle new file mode 100644 index 0000000..c0890b2 --- /dev/null +++ b/fabric-26.3/build.gradle @@ -0,0 +1,30 @@ +plugins { + id 'java' + id 'idea' + id 'eclipse' + id 'net.fabricmc.fabric-loom' +} + +apply from: rootProject.file('gradle/loader.gradle') + +// The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this +// one (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +loom { + runs { + configureEach { + runDir = '../run' + vmArg "-Dmcheli.legacyZip=${legacyZip}" + } + server { + programArgs '--nogui' + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${minecraft_version}" + implementation "net.fabricmc:fabric-loader:${fabric_loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}" +} diff --git a/fabric-26.3/gradle.properties b/fabric-26.3/gradle.properties new file mode 100644 index 0000000..3831c00 --- /dev/null +++ b/fabric-26.3/gradle.properties @@ -0,0 +1,8 @@ +# Minecraft 26.3 on Fabric +loader=fabric +minecraft_version=26.3 +minecraft_version_range=~26.3 +fabric_loader_version=0.20.1 +loader_version_range=>=0.20.0 +fabric_api_version=0.165.0+26.3 +java_version=25 diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 0000000..0e59c8d --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java @@ -0,0 +1,59 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.fabricmc.api.EnvType; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.creativetab.v1.FabricCreativeModeTab; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; + +import java.nio.file.Path; + +final class FabricPlatform implements Platform { + @Override + public String loaderName() { + return "fabric"; + } + + @Override + public Path gameDir() { + return FabricLoader.getInstance().getGameDir(); + } + + @Override + public Path configDir() { + return FabricLoader.getInstance().getConfigDir(); + } + + @Override + public String modVersion() { + return FabricLoader.getInstance().getModContainer(MCHeli.MOD_ID) + .map(c -> c.getMetadata().getVersion().getFriendlyString()).orElse(""); + } + + @Override + public boolean isClient() { + return FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT; + } + + @Override + public void sendToServer(CustomPacketPayload payload) { + ClientPlayNetworking.send(payload); + } + + @Override + public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { + ServerPlayNetworking.send(player, payload); + } + + /** Fabric orders tabs by registration, which already puts ours one after another. */ + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return FabricCreativeModeTab.builder(); + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 0000000..ecc8b27 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.mojang.serialization.MapCodec; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredient; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredientSerializer; +import net.minecraft.core.Holder; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.SlotDisplay; + +import java.util.stream.Stream; + +/** {@link VehicleIngredient} as a Fabric ingredient, read from the {@code fabric:type} key. */ +record FabricVehicleIngredient(String vehicle) implements CustomIngredient { + static final CustomIngredientSerializer SERIALIZER = new CustomIngredientSerializer<>() { + private final MapCodec codec = + VehicleIngredient.codec(FabricVehicleIngredient::new, FabricVehicleIngredient::vehicle); + private final StreamCodec streamCodec = + VehicleIngredient.streamCodec(FabricVehicleIngredient::new, FabricVehicleIngredient::vehicle); + + @Override + public Identifier getIdentifier() { + return VehicleIngredient.ID; + } + + @Override + public MapCodec getCodec() { + return codec; + } + + @Override + public StreamCodec getStreamCodec() { + return streamCodec; + } + }; + + @Override + public boolean test(ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public Stream> items() { + return VehicleIngredient.items(); + } + + @Override + public boolean requiresTesting() { + return true; + } + + @Override + public CustomIngredientSerializer getSerializer() { + return SERIALIZER; + } + + @Override + public SlotDisplay display() { + return VehicleIngredient.display(vehicle); + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java new file mode 100644 index 0000000..e0f1f29 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.CommonHooks; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registration; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredientSerializer; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; + +/** + * The Fabric entry point: everything here only connects Fabric to the shared code. The imported + * content pack is added by {@code PackRepositoryMixin}, since Fabric has no pack finder event. + */ +public final class McheliFabric implements ModInitializer { + + @Override + public void onInitialize() { + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + + ServerLifecycleEvents.SERVER_STARTING.register(CommonHooks::serverAboutToStart); + ServerLifecycleEvents.SERVER_STOPPED.register(server -> CommonHooks.serverStopped()); + ServerTickEvents.END_SERVER_TICK.register(CommonHooks::serverTickEnd); + CommandRegistrationCallback.EVENT.register((dispatcher, context, selection) -> CommonHooks.registerCommands(dispatcher)); + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> CommonHooks.playerLoggedIn(handler.getPlayer())); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> CommonHooks.playerLoggedOut(handler.getPlayer())); + } + + @SuppressWarnings("unchecked") + private static void register(ResourceKey> key) { + Registry registry = (Registry) BuiltInRegistries.REGISTRY.getValue(key.identifier()); + if (registry == null) { + throw new IllegalStateException("No registry " + key.identifier()); + } + Registration.registerAll(key, (id, value) -> Registry.register(registry, id, value)); + } + + /** Client handlers are registered by {@link McheliFabricClient}; handlers run on the main thread. */ + private static void registerPackets() { + for (ModNetwork.Serverbound packet : ModNetwork.SERVERBOUND) { + serverbound(packet); + } + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + clientbound(packet); + } + } + + private static void clientbound(ModNetwork.Clientbound packet) { + PayloadTypeRegistry.clientboundPlay().register(packet.type(), packet.codec()); + } + + private static void serverbound(ModNetwork.Serverbound packet) { + PayloadTypeRegistry.serverboundPlay().register(packet.type(), packet.codec()); + ServerPlayNetworking.registerGlobalReceiver(packet.type(), (msg, context) -> packet.handler().accept(msg, context.player())); + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 0000000..76ff11c --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.particle.v1.ParticleProviderRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.packs.PackType; + +/** + * Client side of the Fabric entry point. The camera, hand, key and rider hooks are mixins (see + * {@code com.jnhprojects.mcheli.fabric.mixin}), since Fabric API has no events for them. + */ +public final class McheliFabricClient implements ClientModInitializer { + + // EntityRendererRegistry is deprecated in favour of a vanilla method that is not public yet. + @SuppressWarnings("deprecation") + @Override + public void onInitializeClient() { + ClientHooks.init(); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ParticleProviderRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ClientHooks.keyMappings().forEach(KeyMappingHelper::registerKeyMapping); + ClientHooks.reloadListeners().forEach(ResourceLoader.get(PackType.CLIENT_RESOURCES)::registerReloadListener); + HudElementRegistry.attachElementAfter(VanillaHudElements.SLEEP, ClientHooks.HUD_LAYER, ClientHooks::drawHud); + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + clientbound(packet); + } + + ClientTickEvents.END_CLIENT_TICK.register(client -> ClientHooks.clientTickEnd()); + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> ClientHooks.loggedIn(client.player)); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> ClientHooks.loggedOut()); + } + + private static void clientbound(ModNetwork.Clientbound packet) { + ClientPlayNetworking.registerGlobalReceiver(packet.type(), (msg, context) -> packet.handler().accept(msg)); + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 0000000..32f7075 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.Camera; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +/** + * Vanilla's camera has no roll. This is {@code Camera#setRotation} with the roll Forge patches in, + * so a banked aircraft tilts the view the same on every loader. + */ +@Mixin(Camera.class) +abstract class CameraMixin implements RollableCamera { + @Shadow + @Final + private static Vector3fc FORWARDS; + @Shadow + @Final + private static Vector3fc UP; + @Shadow + @Final + private static Vector3fc LEFT; + + @Shadow + private float xRot; + @Shadow + private float yRot; + @Shadow + @Final + private Vector3f forwards; + @Shadow + @Final + private Vector3f up; + @Shadow + @Final + private Vector3f left; + @Shadow + @Final + private Quaternionf rotation; + @Shadow + private int matrixPropertiesDirty; + + @Override + public void mcheli$setRotation(float yRot, float xRot, float roll) { + this.xRot = xRot; + this.yRot = yRot; + this.rotation.rotationYXZ((float) Math.PI - yRot * (float) (Math.PI / 180.0), -xRot * (float) (Math.PI / 180.0), + roll * (float) (Math.PI / 180.0)); + FORWARDS.rotate(this.rotation, this.forwards); + UP.rotate(this.rotation, this.up); + LEFT.rotate(this.rotation, this.left); + this.matrixPropertiesDirty |= 3; + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 0000000..7f67f18 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,46 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * The camera hook, at the two places Forge fires its camera angle event (once vanilla has placed + * the camera for the frame, and again before the camera is copied into the render state), and + * the first person hand, which is hidden in some seats. + */ +@Mixin(GameRenderer.class) +abstract class GameRendererMixin { + @Shadow + @Final + private Camera mainCamera; + + @Inject(method = "update", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;update(Lnet/minecraft/client/DeltaTracker;)V", + shift = At.Shift.AFTER)) + private void mcheli$afterCameraUpdate(DeltaTracker deltaTracker, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, deltaTracker.getGameTimeDeltaPartialTick(true), GameRendererMixin::setRotation); + } + + @Inject(method = "extractCamera", at = @At("HEAD")) + private void mcheli$beforeExtractCamera(DeltaTracker deltaTracker, float worldPartialTicks, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, worldPartialTicks, GameRendererMixin::setRotation); + } + + @Inject(method = "renderItemInHand", at = @At("HEAD"), cancellable = true) + private void mcheli$renderItemInHand(CallbackInfo ci) { + if (ClientHooks.hidesHand()) { + ci.cancel(); + } + } + + private static void setRotation(Camera camera, float yaw, float pitch, float roll) { + ((RollableCamera) camera).mcheli$setRotation(yaw, pitch, roll); + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 0000000..aec4e49 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java @@ -0,0 +1,25 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Tilts riders with their vehicle, where Forge fires its living entity render event. */ +@Mixin(LivingEntityRenderer.class) +abstract class LivingEntityRendererMixin { + + @Inject(method = "submit(Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;" + + "Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V", + at = @At("HEAD")) + private void mcheli$beforeSubmit(LivingEntityRenderState state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera, + CallbackInfo ci) { + ClientHooks.beforeRenderLiving(state, pose, camera.cameraEntityPartialTicks); + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 0000000..c7c2695 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.phys.HitResult; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** The use and pick block keys, which Forge and NeoForge let mods take through an input event. */ +@Mixin(Minecraft.class) +abstract class MinecraftMixin { + @Shadow + public @Nullable LocalPlayer player; + @Shadow + public @Nullable HitResult hitResult; + @Shadow + private int rightClickDelay; + + @Inject(method = "startUseItem", at = @At("HEAD"), cancellable = true) + private void mcheli$startUseItem(CallbackInfo ci) { + if (player == null) { + return; + } + ClientHooks.UseKey result = ClientHooks.onUseKey(InteractionHand.MAIN_HAND); + if (result != ClientHooks.UseKey.PASS) { + // Vanilla sets the same delay before it looks at the hands. + rightClickDelay = 4; + if (result == ClientHooks.UseKey.CONSUME_AND_SWING) { + player.swing(InteractionHand.MAIN_HAND, player.getMainHandItem().getInteractAnimation(), false); + } + ci.cancel(); + } + } + + @Inject(method = "pickBlockOrEntity", at = @At("HEAD"), cancellable = true) + private void mcheli$pickBlockOrEntity(CallbackInfo ci) { + if (hitResult != null && hitResult.getType() != HitResult.Type.MISS && ClientHooks.onPickBlockKey()) { + ci.cancel(); + } + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 0000000..965719b --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java @@ -0,0 +1,25 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.CommonHooks; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.server.packs.repository.RepositorySource; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import java.util.Arrays; + +/** + * Adds the imported original content to every pack repository, client resources and server data + * alike, which is what Forge and NeoForge do through their pack finder event. + */ +@Mixin(PackRepository.class) +abstract class PackRepositoryMixin { + + @ModifyVariable(method = "", at = @At("HEAD"), argsOnly = true) + private static RepositorySource[] mcheli$addImportedContent(RepositorySource[] sources) { + RepositorySource[] withOurs = Arrays.copyOf(sources, sources.length + 1); + withOurs[sources.length] = CommonHooks.packSource(); + return withOurs; + } +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java new file mode 100644 index 0000000..f31feae --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +/** Implemented on {@code Camera} by {@link CameraMixin}. */ +public interface RollableCamera { + /** Like vanilla's {@code setRotation}, with a roll in degrees as Forge applies it. */ + void mcheli$setRotation(float yRot, float xRot, float roll); +} diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 0000000..f085c68 --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java @@ -0,0 +1,20 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.sound.RuntimeSoundInstance; +import net.fabricmc.fabric.api.client.sound.v1.FabricSoundInstance; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; + +import java.util.concurrent.CompletableFuture; + +/** Streams content pack sounds from disk through Fabric's sound instance hook. */ +@Mixin(RuntimeSoundInstance.class) +abstract class RuntimeSoundInstanceMixin implements FabricSoundInstance { + + @Override + public CompletableFuture getAudioStream(SoundBufferLibrary loader, Identifier id, boolean repeatInstantly) { + return ((RuntimeSoundInstance) (Object) this).openStream(loader, id, repeatInstantly); + } +} diff --git a/fabric-26.3/src/main/resources/fabric.mod.json b/fabric-26.3/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..8346f4c --- /dev/null +++ b/fabric-26.3/src/main/resources/fabric.mod.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${mod_version}", + "name": "${mod_name}", + "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. A ground-up rework of MCHeli 1.0.3 by EMB4, which stopped at Minecraft 1.7.10. The vehicles come from your own copy of the original: put MC1.7.10_mcheli_1.0.3.zip from the MCHeli CurseForge page into the \"mcheli\" folder of the game directory.", + "authors": ["JayMar921 (rework)", "EMB4 (original author)", "Fureniku (CurseForge maintainer)"], + "contact": { + "homepage": "https://github.com/JnH-Projects/MCHeli-Rework-Forge", + "issues": "https://github.com/JnH-Projects/MCHeli-Rework-Forge/issues", + "sources": "https://github.com/JnH-Projects/MCHeli-Rework-Forge" + }, + "license": "LicenseRef-MCHeli-Rework-1.0", + "icon": "mcheli_logo.png", + "environment": "*", + "entrypoints": { + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] + }, + "mixins": ["mcheli.mixins.json"], + "depends": { + "fabricloader": "${loader_version_range}", + "fabric-api": "*", + "minecraft": "${minecraft_version_range}", + "java": ">=${java_version}" + } +} diff --git a/fabric-26.3/src/main/resources/mcheli.mixins.json b/fabric-26.3/src/main/resources/mcheli.mixins.json new file mode 100644 index 0000000..1b14b27 --- /dev/null +++ b/fabric-26.3/src/main/resources/mcheli.mixins.json @@ -0,0 +1,19 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/forge-1.21.1/build.gradle b/forge-1.21.1/build.gradle new file mode 100644 index 0000000..17131ee --- /dev/null +++ b/forge-1.21.1/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java' + id 'idea' + id 'eclipse' + id 'net.minecraftforge.gradle' +} + +apply from: rootProject.file('gradle/loader.gradle') + +// The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this +// one (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +minecraft { + mappings channel: 'official', version: minecraft_version + + runs { + configureEach { + workingDir = rootProject.layout.projectDirectory.dir("run-${minecraft_version}") + systemProperty 'mcheli.legacyZip', legacyZip + } + + register('client') + + register('server') { + args '--nogui' + } + } +} + +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + mavenCentral() +} + +dependencies { + implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") +} diff --git a/forge-1.21.1/gradle.properties b/forge-1.21.1/gradle.properties new file mode 100644 index 0000000..5bd863d --- /dev/null +++ b/forge-1.21.1/gradle.properties @@ -0,0 +1,7 @@ +# Minecraft 1.21.1 on Forge +loader=forge +minecraft_version=1.21.1 +minecraft_version_range=[1.21.1,1.21.2) +forge_version=52.1.20 +loader_version_range=[52,) +java_version=21 diff --git a/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 0000000..eaf21a8 --- /dev/null +++ b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,111 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.client.CameraAngles; +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraftforge.client.event.AddGuiOverlayLayersEvent; +import net.minecraftforge.client.event.ClientPlayerNetworkEvent; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.client.event.InputEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.client.event.RegisterKeyMappingsEvent; +import net.minecraftforge.client.event.RegisterParticleProvidersEvent; +import net.minecraftforge.client.event.RenderHandEvent; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.client.event.ViewportEvent; +import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.eventbus.api.IEventBus; + +/** Client events on Forge. Only loaded on a physical client. */ +final class ForgeClient { + private ForgeClient() { + } + + static void init(IEventBus modBus) { + ClientHooks.init(); + modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener((RegisterParticleProvidersEvent event) -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); + modBus.addListener((RegisterKeyMappingsEvent event) -> ClientHooks.keyMappings().forEach(event::register)); + modBus.addListener((RegisterClientReloadListenersEvent event) -> + ClientHooks.reloadListeners().values().forEach(event::registerReloadListener)); + modBus.addListener((AddGuiOverlayLayersEvent event) -> event.getLayeredDraw() + .add(ForgeLayeredDraw.POST_SLEEP_STACK, ClientHooks.HUD_LAYER, ClientHooks::drawHud)); + + IEventBus forgeBus = MinecraftForge.EVENT_BUS; + forgeBus.addListener((TickEvent.ClientTickEvent event) -> { + if (event.phase == TickEvent.Phase.END) { + ClientHooks.clientTickEnd(); + } + }); + forgeBus.addListener((ViewportEvent.ComputeCameraAngles event) -> ClientHooks.computeCameraAngles(new Angles(event))); + forgeBus.addListener((RenderHandEvent event) -> event.setCanceled(ClientHooks.hidesHand())); + forgeBus.addListener(EventPriority.NORMAL, false, RenderLivingEvent.Pre.class, + event -> ClientHooks.beforeRenderLiving(event.getEntity(), event.getPoseStack(), event.getPartialTick())); + forgeBus.addListener(ForgeClient::onInteractionKey); + forgeBus.addListener((ClientPlayerNetworkEvent.LoggingIn event) -> ClientHooks.loggedIn(event.getPlayer())); + forgeBus.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> ClientHooks.loggedOut()); + } + + private static void onInteractionKey(InputEvent.InteractionKeyMappingTriggered event) { + if (event.isUseItem()) { + switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> { + } + case CONSUME -> { + event.setSwingHand(false); + event.setCanceled(true); + } + case CONSUME_AND_SWING -> event.setCanceled(true); + } + } else if (event.isPickBlock() && ClientHooks.onPickBlockKey()) { + event.setCanceled(true); + } + } + + private record Angles(ViewportEvent.ComputeCameraAngles event) implements CameraAngles { + @Override + public Camera camera() { + return event.getCamera(); + } + + @Override + public float partialTick() { + return (float) event.getPartialTick(); + } + + @Override + public float yaw() { + return event.getYaw(); + } + + @Override + public float pitch() { + return event.getPitch(); + } + + @Override + public float roll() { + return event.getRoll(); + } + + @Override + public void setYaw(float yaw) { + event.setYaw(yaw); + } + + @Override + public void setPitch(float pitch) { + event.setPitch(pitch); + } + + @Override + public void setRoll(float roll) { + event.setRoll(roll); + } + } +} diff --git a/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 0000000..883dd91 --- /dev/null +++ b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.network.Channel; +import net.minecraftforge.network.ChannelBuilder; +import net.minecraftforge.network.payload.PayloadFlow; + +/** + * Registers the shared packets on a Forge payload channel. Each packet travels under its own id, + * as on NeoForge and Fabric; the channel only adds Forge's version check. + */ +final class ForgeNetwork { + private static Channel channel; + + private ForgeNetwork() { + } + + static void register() { + PayloadFlow flow = ChannelBuilder + .named(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "main")) + .networkProtocolVersion(ModNetwork.PROTOCOL_VERSION) + .payloadChannel() + .play() + .serverbound(); + for (ModNetwork.Serverbound packet : ModNetwork.SERVERBOUND) { + flow = serverbound(flow, packet); + } + flow = flow.clientbound(); + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + flow = clientbound(flow, packet); + } + channel = flow.build(); + } + + static Channel channel() { + return channel; + } + + private static PayloadFlow serverbound( + PayloadFlow flow, ModNetwork.Serverbound packet) { + return flow.add(packet.type(), packet.codec(), (msg, ctx) -> { + ctx.enqueueWork(() -> packet.handler().accept(msg, ctx.getSender())); + ctx.setPacketHandled(true); + }); + } + + private static PayloadFlow clientbound( + PayloadFlow flow, ModNetwork.Clientbound packet) { + return flow.add(packet.type(), packet.codec(), (msg, ctx) -> { + ctx.enqueueWork(() -> packet.handler().accept(msg)); + ctx.setPacketHandled(true); + }); + } +} diff --git a/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 0000000..d8b9d11 --- /dev/null +++ b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.fml.loading.FMLPaths; +import net.minecraftforge.network.PacketDistributor; + +import java.nio.file.Path; + +final class ForgePlatform implements Platform { + @Override + public String loaderName() { + return "forge"; + } + + @Override + public Path gameDir() { + return FMLPaths.GAMEDIR.get(); + } + + @Override + public Path configDir() { + return FMLPaths.CONFIGDIR.get(); + } + + @Override + public String modVersion() { + return ModList.get().getModContainerById(MCHeli.MOD_ID) + .map(c -> c.getModInfo().getVersion().toString()).orElse(""); + } + + @Override + public boolean isClient() { + return FMLEnvironment.dist == Dist.CLIENT; + } + + @Override + public void sendToServer(CustomPacketPayload payload) { + ForgeNetwork.channel().send(payload, PacketDistributor.SERVER.noArg()); + } + + @Override + public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { + ForgeNetwork.channel().send(payload, PacketDistributor.PLAYER.with(player)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 0000000..057d006 --- /dev/null +++ b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java @@ -0,0 +1,80 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.mojang.serialization.MapCodec; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraftforge.common.crafting.ingredients.AbstractIngredient; +import net.minecraftforge.common.crafting.ingredients.IIngredientSerializer; +import org.jspecify.annotations.Nullable; + +/** {@link VehicleIngredient} as a Forge ingredient, read from the {@code type} key. */ +final class ForgeVehicleIngredient extends AbstractIngredient { + static final MapCodec CODEC = VehicleIngredient.codec(ForgeVehicleIngredient::new, ForgeVehicleIngredient::vehicle); + static final StreamCodec STREAM_CODEC = + VehicleIngredient.streamCodec(ForgeVehicleIngredient::new, ForgeVehicleIngredient::vehicle); + static final IIngredientSerializer SERIALIZER = new IIngredientSerializer<>() { + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + public void write(RegistryFriendlyByteBuf buffer, ForgeVehicleIngredient value) { + STREAM_CODEC.encode(buffer, value); + } + + @Override + public ForgeVehicleIngredient read(RegistryFriendlyByteBuf buffer) { + return STREAM_CODEC.decode(buffer); + } + }; + + private final String vehicle; + + private ForgeVehicleIngredient(String vehicle) { + this.vehicle = vehicle; + } + + String vehicle() { + return vehicle; + } + + @Override + public boolean test(@Nullable ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + /** Read when shown, so a vehicle loaded after the recipes still appears. */ + @Override + public ItemStack[] getItems() { + return VehicleIngredient.displayStacks(vehicle).toArray(ItemStack[]::new); + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean isSimple() { + return false; + } + + @Override + public IIngredientSerializer serializer() { + return SERIALIZER; + } + + @Override + public boolean equals(Object o) { + return o instanceof ForgeVehicleIngredient other && vehicle.equals(other.vehicle); + } + + @Override + public int hashCode() { + return vehicle.hashCode(); + } +} diff --git a/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java new file mode 100644 index 0000000..f072658 --- /dev/null +++ b/forge-1.21.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -0,0 +1,74 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.CommonHooks; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.AddPackFindersEvent; +import net.minecraftforge.event.RegisterCommandsEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStoppedEvent; +import net.minecraftforge.eventbus.api.IEventBus; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; + +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) +public final class McheliForge { + + public McheliForge(FMLJavaModLoadingContext context) { + IEventBus modBus = context.getModEventBus(); + Platform.install(new ForgePlatform()); + MCHeli.init(); + + modBus.addListener((RegisterEvent event) -> { + for (ResourceKey> registry : Registration.registries()) { + register(event, Registration.typed(registry)); + } + event.register(ForgeRegistries.Keys.INGREDIENT_SERIALIZERS, + helper -> helper.register(VehicleIngredient.ID, ForgeVehicleIngredient.SERIALIZER)); + }); + ForgeNetwork.register(); + modBus.addListener((AddPackFindersEvent event) -> event.addRepositorySource(CommonHooks.packSource())); + + IEventBus forgeBus = MinecraftForge.EVENT_BUS; + forgeBus.addListener((ServerAboutToStartEvent event) -> CommonHooks.serverAboutToStart(event.getServer())); + forgeBus.addListener((ServerStoppedEvent event) -> CommonHooks.serverStopped()); + forgeBus.addListener((TickEvent.ServerTickEvent event) -> { + if (event.phase == TickEvent.Phase.END) { + CommonHooks.serverTickEnd(event.getServer()); + } + }); + forgeBus.addListener((RegisterCommandsEvent event) -> CommonHooks.registerCommands(event.getDispatcher())); + forgeBus.addListener((PlayerEvent.PlayerLoggedInEvent event) -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedIn(player); + } + }); + forgeBus.addListener((PlayerEvent.PlayerLoggedOutEvent event) -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedOut(player); + } + }); + + if (Platform.get().isClient()) { + ForgeClient.init(modBus); + } + } + + /** Only once Forge asks for that registry, with our entries in declaration order. */ + private static void register(RegisterEvent event, ResourceKey> registry) { + if (event.getRegistryKey().equals(registry)) { + event.register(registry, helper -> Registration.registerAll(registry, helper::register)); + } + } +} diff --git a/forge-1.21.1/src/main/resources/META-INF/mods.toml b/forge-1.21.1/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..1fdc3b4 --- /dev/null +++ b/forge-1.21.1/src/main/resources/META-INF/mods.toml @@ -0,0 +1,33 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +issueTrackerURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge/issues" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" +logoFile="mcheli_logo.png" +authors="${mod_authors}" +credits="EMB4 created the original MCHeli mod and all of its vehicle content. Fureniku maintains the CurseForge project." +description=''' +Helicopters, planes, tanks and vehicles for modern Minecraft Forge. +A ground-up rework of MCHeli 1.0.3 by EMB4, which stopped at Minecraft 1.7.10. +The vehicles come from your own copy of the original: put MC1.7.10_mcheli_1.0.3.zip from the +MCHeli CurseForge page into the "mcheli" folder of the game directory. +''' + +[[dependencies.${mod_id}]] + modId="forge" + mandatory=true + versionRange="${loader_version_range}" + ordering="NONE" + side="BOTH" + +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + versionRange="${minecraft_version_range}" + ordering="NONE" + side="BOTH" diff --git a/forge-26.3/build.gradle b/forge-26.3/build.gradle index 70b4337..c0a19a2 100644 --- a/forge-26.3/build.gradle +++ b/forge-26.3/build.gradle @@ -5,21 +5,7 @@ plugins { id 'net.minecraftforge.gradle' } -version = mod_version -group = mod_group - -base { - archivesName = "mcheli-${minecraft_version}" -} - -java.toolchain.languageVersion = JavaLanguageVersion.of(java_version as int) - -// The shared code is compiled into this module rather than depended on, so the mod stays one -// jar and one module in development runs. The common project checks on its own that these -// sources need nothing from Minecraft. -def common = project(':common') -sourceSets.main.java.srcDir(common.file('src/main/java')) -sourceSets.main.resources.srcDir(common.file('src/main/resources')) +apply from: rootProject.file('gradle/loader.gradle') // The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this // one (git-ignored, see docs/DEVELOPMENT.md). @@ -52,56 +38,3 @@ dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") annotationProcessor "net.minecraftforge:eventbus-validator:${eventbus_version}" } - -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' - options.compilerArgs.addAll(['-Xlint:deprecation', '-Xlint:unchecked']) -} - -def expandProps = [ - mod_id : mod_id, - mod_name : mod_name, - mod_version : version, - mod_authors : mod_authors, - mod_license : mod_license, - minecraft_version_range: minecraft_version_range, - forge_version_range : forge_version_range, - loader_version_range : loader_version_range, -] - -tasks.named('processResources', ProcessResources) { - inputs.properties(expandProps) - filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { - expand expandProps - } -} - -tasks.named('jar', Jar) { - // EMB4's content is never redistributed: players import it from their own copy of the original - // zip (LegacyContent). The repository no longer holds it, but the jar is packed from - // build/sourceSets/main, which can still have copies from older builds and from the IDE. - def shipped = ['assets/mcheli/items/helicopter.json', 'assets/mcheli/items/plane.json', - 'assets/mcheli/items/tank.json', 'assets/mcheli/items/vehicle.json', - // Our own: the night glow sprite (generated, no EMB4 content) and its particle definition. - 'assets/mcheli/particles/glow.json', 'assets/mcheli/textures/particle/glow.png'] - exclude { FileTreeElement element -> - String path = element.relativePath.pathString - !element.directory && path.startsWith('assets/mcheli/') && !path.startsWith('assets/mcheli/lang/') && - !shipped.contains(path) - } - exclude 'data/mcheli/recipe/**' - includeEmptyDirs = false - // The licence travels with every copy of the jar. - from(rootProject.file('LICENSE')) - manifest { - attributes([ - 'Specification-Title' : mod_id, - 'Specification-Vendor' : 'JnH Projects', - 'Specification-Version' : '1', - 'Implementation-Title' : mod_name, - 'Implementation-Version': version, - 'Implementation-Vendor' : 'JnH Projects', - 'Minecraft-Version' : minecraft_version, - ]) - } -} diff --git a/forge-26.3/gradle.properties b/forge-26.3/gradle.properties index 286e31a..2c20a3d 100644 --- a/forge-26.3/gradle.properties +++ b/forge-26.3/gradle.properties @@ -1,8 +1,8 @@ # Minecraft 26.3 on Forge +loader=forge minecraft_version=26.3 minecraft_version_range=[26.3,26.4) forge_version=66.1.0 -forge_version_range=[66,) loader_version_range=[66,) eventbus_version=7.0.6 java_version=25 diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java deleted file mode 100644 index bf183df..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.jnhprojects.mcheli; - -import com.jnhprojects.mcheli.client.ClientSetup; -import com.jnhprojects.mcheli.content.ContentRegistry; -import com.jnhprojects.mcheli.network.ModNetwork; -import com.jnhprojects.mcheli.network.PackSync; -import com.jnhprojects.mcheli.registry.ModCreativeTabs; -import com.jnhprojects.mcheli.registry.ModDataComponents; -import com.jnhprojects.mcheli.registry.ModEntities; -import com.jnhprojects.mcheli.registry.ModItems; -import com.jnhprojects.mcheli.registry.ModParticles; -import com.jnhprojects.mcheli.registry.ModRecipes; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.event.server.ServerAboutToStartEvent; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.fml.common.Mod; -import net.minecraftforge.fml.config.ModConfig; -import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; -import net.minecraftforge.fml.loading.FMLEnvironment; -import net.minecraftforge.fml.loading.FMLPaths; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Mod(MCHeli.MOD_ID) -public final class MCHeli { - public static final String MOD_ID = "mcheli"; - public static final Logger LOGGER = LoggerFactory.getLogger(MCHeli.class); - - private static LegacyContent legacyContent; - private static ContentPacks packs; - private static ContentRegistry content; - - public MCHeli(FMLJavaModLoadingContext context) { - BusGroup modBus = context.getModBusGroup(); - - legacyContent = LegacyContent.prepare(FMLPaths.GAMEDIR.get()); - legacyContent.registerPacks(); - packs = new ContentPacks(FMLPaths.GAMEDIR.get(), legacyContent); - content = packs.load(false); - // The server config (allow list, size limits) exists from here on, before any world loads. - ServerAboutToStartEvent.BUS.addListener(event -> content.replaceWith(packs.load(true))); - context.registerConfig(ModConfig.Type.SERVER, ServerConfig.SPEC); - context.registerConfig(ModConfig.Type.CLIENT, ClientConfig.SPEC); - ModDataComponents.register(modBus); - ModEntities.register(modBus); - ModParticles.register(modBus); - ModItems.register(modBus, content); - ModCreativeTabs.register(modBus); - ModRecipes.register(modBus); - ModNetwork.register(); - PackSync.init(); - McheliCommand.register(); - - if (FMLEnvironment.dist == Dist.CLIENT) { - ClientSetup.init(modBus); - } - } - - /** - * Vehicle and weapon definitions, from the imported original content and the content packs. - * Available from mod construction onwards. Always the same object; a reload changes what it - * holds. - */ - public static ContentRegistry content() { - return content; - } - - /** Where the original vehicle content came from, and why it is missing if it is. */ - public static LegacyContent legacyContent() { - return legacyContent; - } - - /** The content packs in {@code mcheli/packs} and the ones in use. */ - public static ContentPacks packs() { - return packs; - } -} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java deleted file mode 100644 index e29e911..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.jnhprojects.mcheli.client; - -import com.jnhprojects.mcheli.MCHeli; -import com.jnhprojects.mcheli.client.hud.HudRenderer; -import com.jnhprojects.mcheli.client.hud.HudScripts; -import com.jnhprojects.mcheli.client.particle.GlowParticle; -import com.jnhprojects.mcheli.client.render.A10Renderer; -import com.jnhprojects.mcheli.client.render.AircraftRenderer; -import com.jnhprojects.mcheli.client.render.FlareRenderer; -import com.jnhprojects.mcheli.client.render.MeshCache; -import com.jnhprojects.mcheli.client.render.ProjectileRenderer; -import com.jnhprojects.mcheli.client.render.RiderPose; -import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; -import com.jnhprojects.mcheli.entity.ClientAccess; -import com.jnhprojects.mcheli.registry.ModEntities; -import com.jnhprojects.mcheli.registry.ModParticles; -import net.minecraft.client.Minecraft; -import net.minecraftforge.client.event.ClientPlayerNetworkEvent; -import net.minecraftforge.client.event.EntityRenderersEvent; -import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; -import net.minecraftforge.client.event.RegisterKeyMappingsEvent; -import net.minecraftforge.client.event.RegisterParticleProvidersEvent; -import net.minecraftforge.eventbus.api.bus.BusGroup; - -/** Client only wiring. Kept out of {@code MCHeli} so dedicated servers never load these classes. */ -public final class ClientSetup { - private ClientSetup() { - } - - public static void init(BusGroup modBus) { - ClientAccess.install(() -> Minecraft.getInstance().player, ClientPacks.INSTANCE); - EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> { - event.registerEntityRenderer(ModEntities.HELICOPTER.get(), AircraftRenderer::new); - event.registerEntityRenderer(ModEntities.PLANE.get(), AircraftRenderer::new); - event.registerEntityRenderer(ModEntities.TANK.get(), AircraftRenderer::new); - event.registerEntityRenderer(ModEntities.VEHICLE.get(), AircraftRenderer::new); - event.registerEntityRenderer(ModEntities.BULLET.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.ROCKET.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.BOMB.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.AA_MISSILE.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.AS_MISSILE.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.AT_MISSILE.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.TV_MISSILE.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.TORPEDO.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.MARKER_ROCKET.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.DISPENSED_ITEM.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.FLARE.get(), FlareRenderer::new); - event.registerEntityRenderer(ModEntities.A10.get(), A10Renderer::new); - }); - RegisterParticleProvidersEvent.BUS.addListener(event -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); - RegisterKeyMappingsEvent.BUS.addListener(event -> KeyBindings.ALL.forEach(event::register)); - RegisterClientReloadListenersEvent.BUS.addListener(event -> { - event.registerReloadListener(MeshCache.INSTANCE); - event.registerReloadListener(HudScripts.INSTANCE); - }); - // Without the original zip there are no vehicles, so say so where the player will see it. - ClientPlayerNetworkEvent.LoggingIn.BUS.addListener(event -> - MCHeli.legacyContent().problem().ifPresent(event.getPlayer()::sendSystemMessage)); - ClientPlayerNetworkEvent.LoggingOut.BUS.addListener(event -> ClientPacks.INSTANCE.loggedOut()); - CrewController.init(); - WeaponController.init(); - VehiclePicker.init(); - WeaponHud.init(); - HudRenderer.init(); - EngineSoundPlayer.init(); - RiderPose.init(); - } -} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 0000000..2cc5cb7 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,104 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.client.CameraAngles; +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraftforge.client.event.AddGuiOverlayLayersEvent; +import net.minecraftforge.client.event.ClientPlayerNetworkEvent; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.client.event.InputEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.client.event.RegisterKeyMappingsEvent; +import net.minecraftforge.client.event.RegisterParticleProvidersEvent; +import net.minecraftforge.client.event.RenderHandEvent; +import net.minecraftforge.client.event.RenderLivingEvent; +import net.minecraftforge.client.event.ViewportEvent; +import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; +import net.minecraftforge.event.TickEvent; + +/** Client events on Forge. Only loaded on a physical client. */ +final class ForgeClient { + private ForgeClient() { + } + + static void init() { + ClientHooks.init(); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + RegisterParticleProvidersEvent.BUS.addListener(event -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); + RegisterKeyMappingsEvent.BUS.addListener(event -> ClientHooks.keyMappings().forEach(event::register)); + RegisterClientReloadListenersEvent.BUS.addListener(event -> ClientHooks.reloadListeners().values().forEach(event::registerReloadListener)); + AddGuiOverlayLayersEvent.BUS.addListener(event -> event.getLayeredDraw() + .add(ForgeLayeredDraw.POST_SLEEP_STACK, ClientHooks.HUD_LAYER, ClientHooks::drawHud)); + + TickEvent.ClientTickEvent.Post.BUS.addListener(event -> ClientHooks.clientTickEnd()); + ViewportEvent.ComputeCameraAngles.BUS.addListener(event -> ClientHooks.computeCameraAngles(new Angles(event))); + RenderHandEvent.BUS.addListener(ForgeClient::hideHand); + RenderLivingEvent.Pre.BUS.addListener(event -> { + ClientHooks.beforeRenderLiving(event.getState(), event.getPoseStack(), event.getCameraState().cameraEntityPartialTicks); + return false; + }); + InputEvent.InteractionKeyMappingTriggered.UseItem.BUS.addListener(event -> switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> false; + case CONSUME -> { + event.setSwingHand(false); + yield true; + } + case CONSUME_AND_SWING -> true; + }); + InputEvent.InteractionKeyMappingTriggered.PickBlock.BUS.addListener(ForgeClient::pickBlock); + ClientPlayerNetworkEvent.LoggingIn.BUS.addListener(event -> ClientHooks.loggedIn(event.getPlayer())); + ClientPlayerNetworkEvent.LoggingOut.BUS.addListener(event -> ClientHooks.loggedOut()); + } + + private static boolean hideHand(RenderHandEvent event) { + return ClientHooks.hidesHand(); + } + + private static boolean pickBlock(InputEvent.InteractionKeyMappingTriggered.PickBlock event) { + return ClientHooks.onPickBlockKey(); + } + + private record Angles(ViewportEvent.ComputeCameraAngles event) implements CameraAngles { + @Override + public Camera camera() { + return event.getCamera(); + } + + @Override + public float partialTick() { + return event.getPartialTick(); + } + + @Override + public float yaw() { + return event.getYaw(); + } + + @Override + public float pitch() { + return event.getPitch(); + } + + @Override + public float roll() { + return event.getRoll(); + } + + @Override + public void setYaw(float yaw) { + event.setYaw(yaw); + } + + @Override + public void setPitch(float pitch) { + event.setPitch(pitch); + } + + @Override + public void setRoll(float roll) { + event.setRoll(roll); + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 0000000..54a48e8 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraftforge.network.Channel; +import net.minecraftforge.network.ChannelBuilder; +import net.minecraftforge.network.payload.PayloadFlow; + +/** + * Registers the shared packets on a Forge payload channel. Each packet travels under its own id, + * as on NeoForge and Fabric; the channel only adds Forge's version check. + */ +final class ForgeNetwork { + private static Channel channel; + + private ForgeNetwork() { + } + + static void register() { + PayloadFlow flow = ChannelBuilder + .named(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "main")) + .networkProtocolVersion(ModNetwork.PROTOCOL_VERSION) + .payloadChannel() + .play() + .serverbound(); + for (ModNetwork.Serverbound packet : ModNetwork.SERVERBOUND) { + flow = serverbound(flow, packet); + } + flow = flow.clientbound(); + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + flow = clientbound(flow, packet); + } + channel = flow.build(); + } + + static Channel channel() { + return channel; + } + + private static PayloadFlow serverbound( + PayloadFlow flow, ModNetwork.Serverbound packet) { + return flow.add(packet.type(), packet.codec(), (msg, ctx) -> { + ctx.enqueueWork(() -> packet.handler().accept(msg, ctx.getSender())); + ctx.setPacketHandled(true); + }); + } + + private static PayloadFlow clientbound( + PayloadFlow flow, ModNetwork.Clientbound packet) { + return flow.add(packet.type(), packet.codec(), (msg, ctx) -> { + ctx.enqueueWork(() -> packet.handler().accept(msg)); + ctx.setPacketHandled(true); + }); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 0000000..d82312f --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.loading.FMLEnvironment; +import net.minecraftforge.fml.loading.FMLPaths; +import net.minecraftforge.network.PacketDistributor; + +import java.nio.file.Path; + +final class ForgePlatform implements Platform { + @Override + public String loaderName() { + return "forge"; + } + + @Override + public Path gameDir() { + return FMLPaths.GAMEDIR.get(); + } + + @Override + public Path configDir() { + return FMLPaths.CONFIGDIR.get(); + } + + @Override + public String modVersion() { + return ModList.getModContainerById(MCHeli.MOD_ID) + .map(c -> c.getModInfo().getVersion().toString()).orElse(""); + } + + @Override + public boolean isClient() { + return FMLEnvironment.dist == Dist.CLIENT; + } + + @Override + public void sendToServer(CustomPacketPayload payload) { + ForgeNetwork.channel().send(payload, PacketDistributor.SERVER.noArg()); + } + + @Override + public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { + ForgeNetwork.channel().send(payload, PacketDistributor.PLAYER.with(player)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 0000000..b39681a --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.mojang.serialization.MapCodec; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraftforge.common.crafting.ingredients.AbstractIngredient; +import net.minecraftforge.common.crafting.ingredients.IIngredientSerializer; +import org.jspecify.annotations.Nullable; + +/** {@link VehicleIngredient} as a Forge ingredient, read from the {@code type} key. */ +final class ForgeVehicleIngredient extends AbstractIngredient { + static final MapCodec CODEC = VehicleIngredient.codec(ForgeVehicleIngredient::new, ForgeVehicleIngredient::vehicle); + static final StreamCodec STREAM_CODEC = + VehicleIngredient.streamCodec(ForgeVehicleIngredient::new, ForgeVehicleIngredient::vehicle); + static final IIngredientSerializer SERIALIZER = IIngredientSerializer.simple(CODEC, STREAM_CODEC); + + private final String vehicle; + + private ForgeVehicleIngredient(String vehicle) { + super(VehicleIngredient.kindItems()); + this.vehicle = vehicle; + } + + String vehicle() { + return vehicle; + } + + @Override + public boolean test(@Nullable ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public SlotDisplay display() { + return VehicleIngredient.display(vehicle); + } + + @Override + public boolean isSimple() { + return false; + } + + @Override + public IIngredientSerializer serializer() { + return SERIALIZER; + } + + @Override + public boolean equals(Object o) { + return o instanceof ForgeVehicleIngredient other && vehicle.equals(other.vehicle); + } + + @Override + public int hashCode() { + return vehicle.hashCode(); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java new file mode 100644 index 0000000..d9b6998 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -0,0 +1,68 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.CommonHooks; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.event.AddPackFindersEvent; +import net.minecraftforge.event.RegisterCommandsEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStoppedEvent; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; + +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) +public final class McheliForge { + + public McheliForge(FMLJavaModLoadingContext context) { + BusGroup modBus = context.getModBusGroup(); + Platform.install(new ForgePlatform()); + MCHeli.init(); + + RegisterEvent.getBus(modBus).addListener(event -> { + for (ResourceKey> registry : Registration.registries()) { + register(event, Registration.typed(registry)); + } + event.register(ForgeRegistries.Keys.INGREDIENT_SERIALIZERS, + helper -> helper.register(VehicleIngredient.ID, ForgeVehicleIngredient.SERIALIZER)); + }); + ForgeNetwork.register(); + + AddPackFindersEvent.BUS.addListener(event -> event.addRepositorySource(CommonHooks.packSource())); + ServerAboutToStartEvent.BUS.addListener(event -> CommonHooks.serverAboutToStart(event.getServer())); + ServerStoppedEvent.BUS.addListener(event -> CommonHooks.serverStopped()); + TickEvent.ServerTickEvent.Post.BUS.addListener(event -> CommonHooks.serverTickEnd(event.server())); + RegisterCommandsEvent.BUS.addListener(event -> CommonHooks.registerCommands(event.getDispatcher())); + PlayerEvent.PlayerLoggedInEvent.BUS.addListener(event -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedIn(player); + } + }); + PlayerEvent.PlayerLoggedOutEvent.BUS.addListener(event -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedOut(player); + } + }); + + if (Platform.get().isClient()) { + ForgeClient.init(); + } + } + + /** Only once Forge asks for that registry, with our entries in declaration order. */ + private static void register(RegisterEvent event, ResourceKey> registry) { + if (event.getRegistryKey().equals(registry)) { + event.register(registry, helper -> Registration.registerAll(registry, helper::register)); + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java deleted file mode 100644 index 2012f9b..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.jnhprojects.mcheli.item; - -import com.jnhprojects.mcheli.MCHeli; -import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; -import com.jnhprojects.mcheli.registry.ModItems; -import com.mojang.serialization.Codec; -import com.mojang.serialization.MapCodec; -import net.minecraft.core.HolderSet; -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.ByteBufCodecs; -import net.minecraft.network.codec.StreamCodec; -import net.minecraft.world.item.Item; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.ItemStackTemplate; -import net.minecraft.world.item.crafting.Ingredient; -import net.minecraft.world.item.crafting.display.SlotDisplay; -import net.minecraftforge.common.crafting.ingredients.AbstractIngredient; -import net.minecraftforge.common.crafting.ingredients.IIngredientSerializer; -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -/** - * Matches one vehicle, such as an AH-6 in the recipe that turns it into an AH-6X. Every vehicle of - * a kind shares an item, so a plain item ingredient would accept any of them. In JSON: - *
{"type": "mcheli:vehicle", "vehicle": "ah-6"}
- */ -public final class VehicleIngredient extends AbstractIngredient { - public static final MapCodec CODEC = - Codec.STRING.fieldOf("vehicle").xmap(VehicleIngredient::new, VehicleIngredient::vehicle); - public static final StreamCodec STREAM_CODEC = - ByteBufCodecs.STRING_UTF8.map(VehicleIngredient::new, VehicleIngredient::vehicle).cast(); - public static final IIngredientSerializer SERIALIZER = IIngredientSerializer.simple(CODEC, STREAM_CODEC); - - private final String vehicle; - - public VehicleIngredient(String vehicle) { - super(kindItems()); - this.vehicle = vehicle; - } - - @SuppressWarnings("deprecation") - private static HolderSet kindItems() { - return HolderSet.direct(Item::builtInRegistryHolder, - ModItems.HELICOPTER.get(), ModItems.PLANE.get(), ModItems.TANK.get(), ModItems.VEHICLE.get()); - } - - public String vehicle() { - return vehicle; - } - - @Override - public boolean test(@Nullable ItemStack stack) { - return stack != null && vehicle.equals(AircraftItem.vehicleOf(stack)); - } - - @Override - public SlotDisplay display() { - AircraftDefinition def = MCHeli.content().any(vehicle); - return def != null ? new SlotDisplay.ItemStackSlotDisplay(ItemStackTemplate.fromNonEmptyStack(AircraftItem.stackOf(def))) - : SlotDisplay.Empty.INSTANCE; - } - - @Override - public boolean isSimple() { - return false; - } - - @Override - public IIngredientSerializer serializer() { - return SERIALIZER; - } - - @Override - public boolean equals(Object o) { - return o instanceof VehicleIngredient other && vehicle.equals(other.vehicle); - } - - @Override - public int hashCode() { - return Objects.hash(vehicle); - } -} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java deleted file mode 100644 index 705a2ad..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.jnhprojects.mcheli.network; - -import com.jnhprojects.mcheli.MCHeli; -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.StreamCodec; -import net.minecraft.resources.Identifier; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; -import net.minecraftforge.network.ChannelBuilder; -import net.minecraftforge.network.PacketDistributor; -import net.minecraftforge.network.SimpleChannel; - -public final class ModNetwork { - /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - private static final int PROTOCOL_VERSION = 6; - - private static final SimpleChannel CHANNEL = ChannelBuilder - .named(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "main")) - .networkProtocolVersion(PROTOCOL_VERSION) - .simpleChannel() - .play() - .serverbound() - .addMain(PilotStatePacket.class, - StreamCodec.ofMember(PilotStatePacket::encode, PilotStatePacket::decode), - PilotStatePacket::handle) - .addMain(CrewActionPacket.class, - StreamCodec.ofMember(CrewActionPacket::encode, CrewActionPacket::decode), - CrewActionPacket::handle) - .addMain(FireWeaponPacket.class, - StreamCodec.ofMember(FireWeaponPacket::encode, FireWeaponPacket::decode), - FireWeaponPacket::handle) - .addMain(BoardVehiclePacket.class, - StreamCodec.ofMember(BoardVehiclePacket::encode, BoardVehiclePacket::decode), - BoardVehiclePacket::handle) - .addMain(LockWarningPacket.class, - StreamCodec.ofMember(LockWarningPacket::encode, LockWarningPacket::decode), - LockWarningPacket::handle) - .addMain(PackRequestPacket.class, - StreamCodec.ofMember(PackRequestPacket::encode, PackRequestPacket::decode), - PackRequestPacket::handle) - .clientbound() - .addMain(WeaponStatePacket.class, - StreamCodec.ofMember(WeaponStatePacket::encode, WeaponStatePacket::decode), - WeaponStatePacket::handle) - .addMain(ExplosionEffectPacket.class, - StreamCodec.ofMember(ExplosionEffectPacket::encode, ExplosionEffectPacket::decode), - ExplosionEffectPacket::handle) - .addMain(PackListPacket.class, - StreamCodec.ofMember(PackListPacket::encode, PackListPacket::decode), - PackListPacket::handle) - .addMain(PackDataPacket.class, - StreamCodec.ofMember(PackDataPacket::encode, PackDataPacket::decode), - PackDataPacket::handle) - .build(); - - private ModNetwork() { - } - - /** Forces the channel to be built during mod construction. */ - public static void register() { - MCHeli.LOGGER.debug("Network channel {} v{}", CHANNEL.getName(), CHANNEL.getProtocolVersion()); - } - - public static void sendToServer(Object message) { - CHANNEL.send(message, PacketDistributor.SERVER.noArg()); - } - - public static void sendToPlayer(ServerPlayer player, Object message) { - CHANNEL.send(message, PacketDistributor.PLAYER.with(player)); - } - - public static void sendToAll(MinecraftServer server, Object message) { - for (ServerPlayer player : server.getPlayerList().getPlayers()) { - sendToPlayer(player, message); - } - } - - /** Sends to every player in {@code level} within {@code range} blocks of the point. */ - public static void sendNear(ServerLevel level, double x, double y, double z, double range, Object message) { - for (ServerPlayer player : level.players()) { - if (player.distanceToSqr(x, y, z) < range * range) { - sendToPlayer(player, message); - } - } - } -} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java deleted file mode 100644 index 418f31e..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.jnhprojects.mcheli.registry; - -import com.jnhprojects.mcheli.MCHeli; -import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; -import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; -import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; -import com.jnhprojects.mcheli.content.aircraft.TankDefinition; -import com.jnhprojects.mcheli.entity.AircraftEntity; -import com.jnhprojects.mcheli.entity.GroundVehicleEntity; -import com.jnhprojects.mcheli.entity.HelicopterEntity; -import com.jnhprojects.mcheli.entity.PlaneEntity; -import com.jnhprojects.mcheli.entity.TankEntity; -import com.jnhprojects.mcheli.entity.projectile.A10Entity; -import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; -import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; -import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; -import com.jnhprojects.mcheli.entity.projectile.BombEntity; -import com.jnhprojects.mcheli.entity.projectile.BulletEntity; -import com.jnhprojects.mcheli.entity.projectile.DispensedItemEntity; -import com.jnhprojects.mcheli.entity.projectile.FlareEntity; -import com.jnhprojects.mcheli.entity.projectile.MarkerRocketEntity; -import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; -import com.jnhprojects.mcheli.entity.projectile.RocketEntity; -import com.jnhprojects.mcheli.entity.projectile.TorpedoEntity; -import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; -import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.MobCategory; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.ForgeRegistries; -import net.minecraftforge.registries.RegistryObject; - -/** - * One entity type per vehicle kind. The concrete vehicle is stored on the entity, the same approach - * the original took, so adding content never changes the entity registry. - */ -public final class ModEntities { - private static final DeferredRegister> ENTITY_TYPES = DeferredRegister.create(ForgeRegistries.ENTITY_TYPES, MCHeli.MOD_ID); - - public static final RegistryObject> HELICOPTER = register("helicopter", HelicopterEntity::new); - public static final RegistryObject> PLANE = register("plane", PlaneEntity::new); - public static final RegistryObject> TANK = register("tank", TankEntity::new); - public static final RegistryObject> VEHICLE = register("vehicle", GroundVehicleEntity::new); - - public static final RegistryObject> BULLET = projectile("bullet", BulletEntity::new); - public static final RegistryObject> ROCKET = projectile("rocket", RocketEntity::new); - public static final RegistryObject> BOMB = projectile("bomb", BombEntity::new); - public static final RegistryObject> AA_MISSILE = projectile("aa_missile", AAMissileEntity::new); - public static final RegistryObject> AS_MISSILE = projectile("as_missile", ASMissileEntity::new); - public static final RegistryObject> AT_MISSILE = projectile("at_missile", ATMissileEntity::new); - public static final RegistryObject> TV_MISSILE = projectile("tv_missile", TvMissileEntity::new); - public static final RegistryObject> TORPEDO = projectile("torpedo", TorpedoEntity::new); - public static final RegistryObject> MARKER_ROCKET = projectile("marker_rocket", MarkerRocketEntity::new); - public static final RegistryObject> DISPENSED_ITEM = projectile("dispensed_item", DispensedItemEntity::new); - public static final RegistryObject> FLARE = ENTITY_TYPES.register("flare", () -> EntityType.Builder - .of(FlareEntity::new, MobCategory.MISC) - .sized(1.0F, 1.0F) - .clientTrackingRange(16) - .updateInterval(2) - .fireImmune() - .noSave() - .build(ENTITY_TYPES.key("flare"))); - public static final RegistryObject> A10 = ENTITY_TYPES.register("a10", () -> EntityType.Builder - .of(A10Entity::new, MobCategory.MISC) - .sized(5.0F, 3.0F) - .clientTrackingRange(32) - .updateInterval(1) - .fireImmune() - .noSave() - .build(ENTITY_TYPES.key("a10"))); - - private ModEntities() { - } - - /** Projectiles are a metre cube like the original's, and never saved. */ - private static RegistryObject> projectile(String name, EntityType.EntityFactory factory) { - return ENTITY_TYPES.register(name, () -> EntityType.Builder.of(factory, MobCategory.MISC) - .sized(1.0F, 1.0F) - .clientTrackingRange(16) - .updateInterval(1) - .fireImmune() - .noSave() - .build(ENTITY_TYPES.key(name))); - } - - private static RegistryObject> register(String name, EntityType.EntityFactory factory) { - return ENTITY_TYPES.register(name, () -> EntityType.Builder.of(factory, MobCategory.MISC) - .sized(2.0F, 0.7F) - .clientTrackingRange(16) - .updateInterval(1) - .fireImmune() - .build(ENTITY_TYPES.key(name))); - } - - /** The entity type that hosts a definition of this kind. */ - public static EntityType typeFor(AircraftDefinition def) { - if (def instanceof HelicopterDefinition) { - return HELICOPTER.get(); - } - if (def instanceof PlaneDefinition) { - return PLANE.get(); - } - if (def instanceof TankDefinition) { - return TANK.get(); - } - return VEHICLE.get(); - } - - public static void register(BusGroup modBus) { - ENTITY_TYPES.register(modBus); - } -} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java deleted file mode 100644 index d96ba19..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.jnhprojects.mcheli.registry; - -import com.jnhprojects.mcheli.MCHeli; -import com.jnhprojects.mcheli.item.VehicleIngredient; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.item.crafting.RecipeHolder; -import net.minecraftforge.common.crafting.ingredients.IIngredientSerializer; -import net.minecraftforge.event.entity.player.PlayerEvent; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.ForgeRegistries; - -import java.util.List; - -/** - * The vehicle recipes live in {@code data/mcheli/recipe} of the imported content pack, generated - * from the definitions' {@code AddRecipe} lines. The original listed them on its drafting table; - * until that is ported, every MCHeli recipe goes into a player's recipe book when they join, so they can be found. - */ -public final class ModRecipes { - private static final DeferredRegister> INGREDIENTS = - DeferredRegister.create(ForgeRegistries.Keys.INGREDIENT_SERIALIZERS, MCHeli.MOD_ID); - - static { - INGREDIENTS.register("vehicle", () -> VehicleIngredient.SERIALIZER); - } - - private ModRecipes() { - } - - public static void register(BusGroup modBus) { - INGREDIENTS.register(modBus); - PlayerEvent.PlayerLoggedInEvent.BUS.addListener(event -> { - if (event.getEntity() instanceof ServerPlayer player) { - List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() - .filter(holder -> holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID)) - .toList(); - player.awardRecipes(ours); - } - }); - } -} diff --git a/forge-26.3/src/main/resources/META-INF/mods.toml b/forge-26.3/src/main/resources/META-INF/mods.toml index b6481de..1fdc3b4 100644 --- a/forge-26.3/src/main/resources/META-INF/mods.toml +++ b/forge-26.3/src/main/resources/META-INF/mods.toml @@ -21,7 +21,7 @@ MCHeli CurseForge page into the "mcheli" folder of the game directory. [[dependencies.${mod_id}]] modId="forge" mandatory=true - versionRange="${forge_version_range}" + versionRange="${loader_version_range}" ordering="NONE" side="BOTH" diff --git a/gradle.properties b/gradle.properties index 8e32c12..e2d93a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,13 +13,13 @@ 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.2 +mod_version=1.1.0-beta.3 mod_authors=JayMar921 (rework), EMB4 (original author), Fureniku (CurseForge maintainer) mod_license=MCHeli Rework Licence 1.0 (see LICENSE) # Platform modules built by this project, one per Minecraft version. Each has its own # gradle.properties with the Minecraft, loader and Java versions. -platforms=forge-26.3 +platforms=forge-26.3,neoforge-26.3,fabric-26.3,forge-1.21.1,neoforge-1.21.1,fabric-1.21.1 # Libraries the common module compiles against. Every supported Minecraft version ships them. slf4j_version=2.0.17 diff --git a/gradle/loader.gradle b/gradle/loader.gradle new file mode 100644 index 0000000..023e7d3 --- /dev/null +++ b/gradle/loader.gradle @@ -0,0 +1,88 @@ +// Shared by every loader module: which sources go in, how resources are expanded and what the jar +// may contain. Applied after the loader plugin, with these project properties set in the +// module's gradle.properties: loader, minecraft_version, java_version. + +version = mod_version +group = mod_group + +base { + // The loader and Minecraft version are part of the name, such as mcheli-fabric-26.3-1.1.0.jar. + archivesName = "mcheli-${loader}-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(java_version as int) + +// common holds the version-free code, minecraft- the code shared by every loader on that +// Minecraft version. Both are compiled into this module, so the mod stays one jar. common checks +// on its own that it needs nothing from Minecraft; compiling minecraft- under three +// loaders keeps it free of any one loader's API. +def shared = rootProject.file("minecraft-${minecraft_version}") +sourceSets.main.java.srcDir(project(':common').file('src/main/java')) +sourceSets.main.resources.srcDir(project(':common').file('src/main/resources')) +sourceSets.main.java.srcDir(new File(shared, 'src/main/java')) +sourceSets.main.resources.srcDir(new File(shared, 'src/main/resources')) + +repositories { + mavenCentral() +} + +dependencies { + // Annotations only; Minecraft ships JSpecify from 1.21.6 on, so older versions need it to compile. + compileOnly "org.jspecify:jspecify:${jspecify_version}" +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = java_version as int + options.compilerArgs.addAll(['-Xlint:deprecation', '-Xlint:unchecked']) +} + +def expandProps = [ + mod_id : mod_id, + mod_name : mod_name, + mod_version : version, + mod_authors : mod_authors, + mod_license : mod_license, + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + loader_version_range : loader_version_range, + java_version : java_version, +] + +tasks.named('processResources', ProcessResources) { + inputs.properties(expandProps) + filesMatching(['META-INF/mods.toml', 'META-INF/neoforge.mods.toml', 'fabric.mod.json', 'pack.mcmeta']) { + expand expandProps + } +} + +tasks.named('jar', Jar) { + // EMB4's content is never redistributed: players import it from their own copy of the original + // zip (LegacyContent). The repository no longer holds it, but the jar is packed from the + // resource output, which can still have copies from older builds and from the IDE. + def shipped = ['assets/mcheli/items/helicopter.json', 'assets/mcheli/items/plane.json', + 'assets/mcheli/items/tank.json', 'assets/mcheli/items/vehicle.json', + // Our own: the night glow sprite (generated, no EMB4 content) and its particle definition. + 'assets/mcheli/particles/glow.json', 'assets/mcheli/textures/particle/glow.png'] + exclude { FileTreeElement element -> + String path = element.relativePath.pathString + !element.directory && path.startsWith('assets/mcheli/') && !path.startsWith('assets/mcheli/lang/') && + !shipped.contains(path) + } + exclude 'data/mcheli/recipe/**' + includeEmptyDirs = false + // The licence travels with every copy of the jar. + from(rootProject.file('LICENSE')) + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : 'JnH Projects', + 'Specification-Version' : '1', + 'Implementation-Title' : mod_name, + 'Implementation-Version': version, + 'Implementation-Vendor' : 'JnH Projects', + 'Minecraft-Version' : minecraft_version, + 'Mod-Loader' : loader, + ]) + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 54f208b..d4fc8e0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ClientConfig.java similarity index 66% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ClientConfig.java index 299d482..ff25884 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ClientConfig.java @@ -1,19 +1,22 @@ package com.jnhprojects.mcheli; -import net.minecraftforge.common.ForgeConfigSpec; +import com.jnhprojects.mcheli.config.ConfigSpec; + +import java.io.IOException; +import java.nio.file.Path; /** * Per player look and feel, in {@code config/mcheli-client.toml}. Nothing here changes how the * game plays, so each player picks for themselves and the server never sees it. */ public final class ClientConfig { - public static final ForgeConfigSpec SPEC; + public static final ConfigSpec SPEC; - private static final ForgeConfigSpec.BooleanValue NIGHT_GLOW; - private static final ForgeConfigSpec.IntValue PACK_CACHE_SIZE; + private static final ConfigSpec.BooleanValue NIGHT_GLOW; + private static final ConfigSpec.IntValue PACK_CACHE_SIZE; static { - ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + ConfigSpec.Builder builder = ConfigSpec.builder(); builder.push("effects"); NIGHT_GLOW = builder @@ -36,6 +39,16 @@ public final class ClientConfig { private ClientConfig() { } + /** Reads {@code config/mcheli-client.toml}, writing it with the defaults if it is new. */ + public static void load(Path configDir) { + Path file = configDir.resolve(MCHeli.MOD_ID + "-client.toml"); + try { + SPEC.load(file); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not read {}; using the defaults", file, e); + } + } + public static boolean nightGlow() { return SPEC.isLoaded() ? NIGHT_GLOW.get() : NIGHT_GLOW.getDefault(); } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 0000000..d42f522 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.network.PackSync; +import com.mojang.brigadier.CommandDispatcher; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.packs.repository.RepositorySource; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.storage.LevelResource; + +import java.util.List; + +/** + * What the mod does on game events that both sides see. Each loader module calls these from its + * own events; nothing here knows which loader that is. + */ +public final class CommonHooks { + private CommonHooks() { + } + + /** + * Adds the imported original content to the resource packs (client) and data packs (server). + * Without the original zip there is nothing to add. + */ + public static RepositorySource packSource() { + return onLoad -> { + // Fabric builds some pack repositories before our initializer has run. + LegacyContent content = MCHeli.legacyContent(); + if (content != null) { + content.pack().ifPresent(onLoad); + } + }; + } + + /** Before the worlds load: read the world's server config, then load the content packs it allows. */ + public static void serverAboutToStart(MinecraftServer server) { + ServerConfig.load(server.getWorldPath(LevelResource.ROOT)); + MCHeli.content().replaceWith(MCHeli.packs().load(true)); + } + + public static void serverStopped() { + ServerConfig.unload(); + } + + public static void serverTickEnd(MinecraftServer server) { + PackSync.pump(server); + } + + public static void registerCommands(CommandDispatcher dispatcher) { + McheliCommand.register(dispatcher); + } + + /** + * The vehicle recipes live in {@code data/mcheli/recipe} of the imported content pack. The + * original listed them on its drafting table; until that is ported, every MCHeli recipe goes + * into a player's recipe book when they join, so they can be found. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() + .filter(holder -> holder.id().getNamespace().equals(MCHeli.MOD_ID)) + .toList(); + player.awardRecipes(ours); + } + + public static void playerLoggedOut(ServerPlayer player) { + PackSync.onLogout(player); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentReload.java similarity index 75% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentReload.java index 44df1d1..7846b7b 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentReload.java @@ -3,11 +3,12 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.network.PackSync; import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelResource; /** * Reads the vehicle and weapon definitions from disk again while the game runs, for - * {@code /mcheli reload}: the imported content and the content packs, with the server config - * applied. Vehicles in the world pick up their new definition on their next tick (see + * {@code /mcheli reload}: the server config, then the imported content and the content packs. + * Vehicles in the world pick up their new definition on their next tick (see * {@code AircraftEntity#definition}). Every player gets the new pack list, downloads what changed * and rebuilds their content the same way. */ @@ -17,6 +18,7 @@ private ContentReload() { /** @return the newly loaded content, already in use */ public static ContentRegistry reload(MinecraftServer server) { + ServerConfig.load(server.getWorldPath(LevelResource.ROOT)); ContentRegistry fresh = MCHeli.packs().load(true); MCHeli.content().replaceWith(fresh); MCHeli.LOGGER.info("Content reloaded: {} vehicles, {} weapons", fresh.all().size(), fresh.weapons().size()); diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/LegacyContent.java new file mode 100644 index 0000000..12a5862 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -0,0 +1,275 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ImportTarget; +import com.jnhprojects.mcheli.content.LegacyImporter; +import net.minecraft.network.chat.Component; +import net.minecraft.server.packs.PackLocationInfo; +import net.minecraft.server.packs.PackSelectionConfig; +import net.minecraft.server.packs.PathPackResources; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackCompatibility; +import net.minecraft.server.packs.repository.PackSource; +import net.minecraft.world.flag.FeatureFlagSet; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Properties; +import java.util.stream.Stream; + +/** + * The original vehicles, models, textures and sounds, imported from the player's own copy of + * MCHeli 1.0.3. + * + *

That content is EMB4's and is not redistributed with this mod. Players download the original + * zip from the MCHeli CurseForge page and put it in {@code /mcheli}. On start the zip is + * converted once by {@link LegacyImporter} into {@code mcheli/imported}, which then serves as a + * required resource pack and data pack. The zip is converted again only when it changes or the + * importer's output format does; once imported it may be deleted. + * + *

Development runs point the {@code mcheli.legacyZip} system property at the zip instead. + */ +public final class LegacyContent { + /** The file name the original download has. Any zip with the same layout works. */ + public static final String ZIP_NAME = "MC1.7.10_mcheli_1.0.3.zip"; + + private static final String PACK_ID = "mcheli_content"; + private static final String MARKER = "import.properties"; + /** The pack layout this Minecraft version reads. */ + private static final ImportTarget TARGET = ImportTarget.V1_21_1; + + private final Path folder; + private final Path packRoot; + private @Nullable Component problem; + private volatile @Nullable Map icons; + + private LegacyContent(Path folder) { + this.folder = folder; + this.packRoot = folder.resolve("imported"); + } + + /** Imports the zip if needed. Never throws; a missing or broken zip leaves the mod without content. */ + static LegacyContent prepare(Path gameDir) { + LegacyContent content = new LegacyContent(gameDir.resolve(MCHeli.MOD_ID)); + try { + content.importIfNeeded(); + } catch (IOException | UncheckedIOException e) { + MCHeli.LOGGER.error("Could not import the original MCHeli content", e); + content.problem = Component.translatable("message.mcheli.content.import_failed", e.getMessage()); + } + return content; + } + + /** The imported {@code assets/mcheli} folder. It does not exist when nothing was imported. */ + public Path assets() { + return packRoot.resolve("assets").resolve(MCHeli.MOD_ID); + } + + /** + * The {@code custom_model_data} that shows this original vehicle's icon, from the list the + * import wrote ({@link LegacyImporter#ICONS_FILE}); empty for a vehicle it did not import. + */ + public OptionalInt iconIndex(String vehicle) { + Map map = icons; + if (map == null) { + map = readIcons(); + icons = map; + } + Integer index = map.get(vehicle); + return index != null ? OptionalInt.of(index) : OptionalInt.empty(); + } + + private Map readIcons() { + Properties list = new Properties(); + try (InputStream in = Files.newInputStream(packRoot.resolve(LegacyImporter.ICONS_FILE))) { + list.load(in); + } catch (IOException e) { + return Map.of(); + } + Map map = new HashMap<>(); + for (String name : list.stringPropertyNames()) { + try { + map.put(name, Integer.parseInt(list.getProperty(name).trim())); + } catch (NumberFormatException ignored) { + // A hand edited list; that vehicle shows its kind's model. + } + } + return Map.copyOf(map); + } + + /** Why the vehicles are missing, for the player; empty when the content is in place. */ + public Optional problem() { + return Optional.ofNullable(problem); + } + + /** + * The imported folder as a pack, for both the client resource packs and the server data packs. + * Empty when nothing was imported. + */ + public Optional pack() { + if (!Files.isDirectory(packRoot)) { + return Optional.empty(); + } + PackLocationInfo location = new PackLocationInfo(PACK_ID, + Component.translatable("pack.mcheli.content"), PackSource.BUILT_IN, Optional.empty()); + Pack.Metadata metadata = new Pack.Metadata(Component.translatable("pack.mcheli.content.description"), + PackCompatibility.COMPATIBLE, FeatureFlagSet.of(), List.of()); + // Required, so it is enabled in every world and cannot be switched off. It sits at the + // bottom so that regular resource packs can still replace its textures and models. + return Optional.of(new Pack(location, new PathPackResources.PathResourcesSupplier(packRoot), metadata, + new PackSelectionConfig(true, Pack.Position.BOTTOM, false))); + } + + private void importIfNeeded() throws IOException { + Path zip = findZip(); + if (zip == null) { + if (Files.isDirectory(packRoot)) { + MCHeli.LOGGER.info("Using the MCHeli content imported earlier into {}", packRoot); + } else { + MCHeli.LOGGER.error("MCHeli content not found. Download {} from the MCHeli CurseForge page and put it in {}", + ZIP_NAME, folder.toAbsolutePath()); + problem = Component.translatable("message.mcheli.content.missing", ZIP_NAME, folder.toAbsolutePath().toString()); + } + return; + } + + Properties source = describe(zip); + if (source.equals(readMarker())) { + return; + } + + MCHeli.LOGGER.info("Importing MCHeli content from {}", zip); + Path staging = folder.resolve("imported.tmp"); + deleteTree(staging); + LegacyImporter.Summary summary = LegacyImporter.run(zip, staging, TARGET); + try (OutputStream out = Files.newOutputStream(staging.resolve(MARKER))) { + source.store(out, "Written by MCHeli Rework. Delete this folder to import again."); + } + swapIn(staging); + + MCHeli.LOGGER.info("Imported {} files, {} sounds, {} item models and {} recipes into {}", + summary.files(), summary.sounds(), summary.itemModels(), summary.recipes(), packRoot); + for (String skipped : summary.skippedRecipes()) { + MCHeli.LOGGER.info("Recipe left out: {}", skipped); + } + } + + /** + * Replaces the previous import with the fresh one in {@code staging}. + * + *

On Windows a folder that was just deleted keeps its name for a moment while another + * program (a file watcher, a virus scanner) still has a handle in it, and renaming onto that + * name fails with "access denied". So the old import is moved out of the way rather than + * deleted, the renames are retried for a few seconds, and the old copy is deleted last, on a + * best effort basis; a copy left behind is removed on the next import. + */ + private void swapIn(Path staging) throws IOException { + Path old = folder.resolve("imported.old"); + deleteQuietly(old); + if (Files.exists(packRoot)) { + try { + moveWithRetry(packRoot, old); + } catch (IOException e) { + deleteTree(packRoot); + } + } + moveWithRetry(staging, packRoot); + deleteQuietly(old); + } + + private static void moveWithRetry(Path from, Path to) throws IOException { + for (int attempt = 1; ; attempt++) { + try { + Files.move(from, to); + return; + } catch (FileSystemException e) { + if (attempt >= 15) { + throw e; + } + try { + Thread.sleep(50L * attempt); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + private static void deleteQuietly(Path root) { + try { + deleteTree(root); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not delete {}; it is removed on the next import", root, e); + } + } + + private @Nullable Path findZip() throws IOException { + String configured = System.getProperty("mcheli.legacyZip"); + if (configured != null && !configured.isBlank()) { + Path zip = Path.of(configured); + if (Files.isRegularFile(zip)) { + return zip; + } + MCHeli.LOGGER.warn("mcheli.legacyZip points to {}, which does not exist", zip); + } + + Files.createDirectories(folder); + if (Files.isRegularFile(folder.resolve(ZIP_NAME))) { + return folder.resolve(ZIP_NAME); + } + // Browsers rename repeated downloads, so take any zip, the same way every start. + try (Stream files = Files.list(folder)) { + return files.filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) + .filter(Files::isRegularFile) + .sorted() + .findFirst() + .orElse(null); + } + } + + /** What the current import was made from. Comparing size and time is enough to notice a new zip. */ + private static Properties describe(Path zip) throws IOException { + Properties p = new Properties(); + p.setProperty("format", Integer.toString(LegacyImporter.FORMAT)); + p.setProperty("target", TARGET.name()); + p.setProperty("source", zip.toAbsolutePath().normalize().toString()); + p.setProperty("size", Long.toString(Files.size(zip))); + p.setProperty("modified", Long.toString(Files.getLastModifiedTime(zip).toMillis())); + return p; + } + + private Properties readMarker() throws IOException { + Properties p = new Properties(); + Path marker = packRoot.resolve(MARKER); + if (Files.isRegularFile(marker)) { + try (InputStream in = Files.newInputStream(marker)) { + p.load(in); + } + } + return p; + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(p); + } + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 0000000..21d4185 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModCreativeTabs; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.registry.ModParticles; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The mod, minus the loader. Each loader module's entry point installs its {@link Platform}, calls + * {@link #init()}, registers what {@code Registration} collected and wires its events to + * {@link CommonHooks} (and, on a client, {@code ClientHooks}). + */ +public final class MCHeli { + public static final String MOD_ID = "mcheli"; + public static final Logger LOGGER = LoggerFactory.getLogger(MCHeli.class); + + private static LegacyContent legacyContent; + private static ContentPacks packs; + private static ContentRegistry content; + + private MCHeli() { + } + + /** + * Imports the original content if needed, loads the definitions and declares the registry + * entries. The vehicle list must be known here: 1.0.0 had an item per vehicle, and those items + * are still registered so old worlds keep them. + */ + public static void init() { + Platform platform = Platform.get(); + LOGGER.info("MCHeli Rework {} on {}", platform.modVersion(), platform.loaderName()); + legacyContent = LegacyContent.prepare(platform.gameDir()); + packs = new ContentPacks(platform.gameDir(), legacyContent); + content = packs.load(false); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModItems.init(content); + ModCreativeTabs.init(); + } + + /** + * Vehicle and weapon definitions, from the imported original content and the content packs. + * Available from mod construction onwards. Always the same object; a reload changes what it + * holds. + */ + public static ContentRegistry content() { + return content; + } + + /** Where the original vehicle content came from, and why it is missing if it is. */ + public static LegacyContent legacyContent() { + return legacyContent; + } + + /** The content packs in {@code mcheli/packs} and the ones in use. */ + public static ContentPacks packs() { + return packs; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 0000000..f70a747 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,135 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.item.AircraftItem; +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 net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * {@code /mcheli}: what content is loaded, and operator tools to hand out vehicles and reload the + * definitions and content packs. + *

+ *   /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)
+ * 
+ */ +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 McheliCommand() { + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(Commands.literal(MCHeli.MOD_ID) + .executes(McheliCommand::status) + .then(Commands.literal("list") + .executes(ctx -> list(ctx, null)) + .then(Commands.argument("kind", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(KINDS, builder)) + .executes(ctx -> list(ctx, StringArgumentType.getString(ctx, "kind"))))) + .then(Commands.literal("packs") + .executes(McheliCommand::packs)) + .then(Commands.literal("give") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("targets", EntityArgument.players()) + .then(Commands.argument("vehicle", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest( + MCHeli.content().all().stream().map(AircraftDefinition::name), builder)) + .executes(ctx -> give(ctx, 1)) + .then(Commands.argument("count", IntegerArgumentType.integer(1, 64)) + .executes(ctx -> give(ctx, IntegerArgumentType.getInteger(ctx, "count"))))))) + .then(Commands.literal("reload") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::reload))); + } + + private static int status(CommandContext ctx) { + ContentRegistry content = MCHeli.content(); + CommandSourceStack source = ctx.getSource(); + MCHeli.legacyContent().problem().ifPresent(source::sendFailure); + source.sendSuccess(() -> Component.translatable("commands.mcheli.status", + content.helicopters().size(), content.planes().size(), content.tanks().size(), content.vehicles().size(), + content.weapons().size(), MCHeli.legacyContent().assets().toString()), false); + List packs = MCHeli.packs().active(); + source.sendSuccess(() -> Component.translatable("commands.mcheli.status.packs", packs.size(), + packs.stream().map(ContentPack::id).collect(Collectors.joining(", "))), false); + return content.all().size(); + } + + private static int packs(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List packs = MCHeli.packs().active(); + if (packs.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.none", MCHeli.packs().folder().toString()), false); + } else { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.header", packs.size(), MCHeli.packs().folder().toString()), false); + } + ContentRegistry content = MCHeli.content(); + for (ContentPack pack : packs) { + long vehicles = content.all().stream().filter(def -> def.pack().equals(pack.id())).count(); + 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); + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + + private static int list(CommandContext ctx, String kind) { + List defs = MCHeli.content().all().stream() + .filter(def -> kind == null || def.kind().equals(kind)) + .toList(); + String names = defs.stream().map(AircraftDefinition::name).collect(Collectors.joining(", ")); + ctx.getSource().sendSuccess(() -> Component.translatable("commands.mcheli.list", defs.size(), names), false); + return defs.size(); + } + + private static int give(CommandContext ctx, int count) throws CommandSyntaxException { + String name = StringArgumentType.getString(ctx, "vehicle"); + AircraftDefinition def = MCHeli.content().any(name); + if (def == null) { + throw UNKNOWN_VEHICLE.create(name); + } + Collection targets = EntityArgument.getPlayers(ctx, "targets"); + for (ServerPlayer player : targets) { + // Vehicle items do not stack, so each one is a separate stack. + for (int i = 0; i < count; i++) { + player.getInventory().placeItemBackInInventory(AircraftItem.stackOf(def)); + } + } + Component vehicle = AircraftItem.stackOf(def).getHoverName(); + ctx.getSource().sendSuccess(() -> Component.translatable("commands.mcheli.give", count, vehicle, targets.size()), true); + return targets.size(); + } + + private static int reload(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + ContentRegistry fresh = ContentReload.reload(source.getServer()); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reload.success", + fresh.all().size(), fresh.weapons().size(), MCHeli.packs().active().size()), true); + MCHeli.packs().reportProblems(source::sendFailure); + return fresh.all().size(); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java new file mode 100644 index 0000000..5bbb322 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli; + +import net.minecraft.core.Holder; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Sound events from {@code assets/mcheli/sounds.json}. + * + *

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. + */ +public final class ModSounds { + private static final Map EVENTS = new ConcurrentHashMap<>(); + + private ModSounds() { + } + + public static SoundEvent get(String name) { + return EVENTS.computeIfAbsent(name, n -> SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, n))); + } + + /** 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); + } + + /** 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); + } + + /** 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())); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java similarity index 73% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java index ab5e7db..eef7dc3 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -1,32 +1,35 @@ package com.jnhprojects.mcheli; -import net.minecraftforge.common.ForgeConfigSpec; +import com.jnhprojects.mcheli.config.ConfigSpec; +import java.io.IOException; +import java.nio.file.Path; import java.util.List; import java.util.Locale; /** * Server rules, the part of the original {@code mcheli.cfg} that decides how the game plays. - * Forge keeps the file per world in {@code serverconfig/mcheli-server.toml}, sends nothing to - * clients (only the server applies these rules) and picks up edits while the server runs. + * The file lives per world in {@code serverconfig/mcheli-server.toml}, where Forge kept it, and is + * never sent to clients (only the server applies these rules). It is read when the world starts + * and again on {@code /mcheli reload}. * *

The values only exist while a world is loaded. Before that, and on clients, the getters return * the defaults, which match 1.0.0. */ public final class ServerConfig { - public static final ForgeConfigSpec SPEC; + public static final ConfigSpec SPEC; - private static final ForgeConfigSpec.BooleanValue EXPLOSIONS_BREAK_BLOCKS; - private static final ForgeConfigSpec.BooleanValue EXPLOSIONS_START_FIRES; - private static final ForgeConfigSpec.DoubleValue VEHICLE_DAMAGE_FACTOR; - private static final ForgeConfigSpec.BooleanValue INFINITE_AMMO; - private static final ForgeConfigSpec.ConfigValue> DISABLED_VEHICLES; - private static final ForgeConfigSpec.ConfigValue> PACK_ALLOW_LIST; - private static final ForgeConfigSpec.IntValue MAX_PACK_SIZE; - private static final ForgeConfigSpec.IntValue MAX_TOTAL_SIZE; + private static final ConfigSpec.BooleanValue EXPLOSIONS_BREAK_BLOCKS; + private static final ConfigSpec.BooleanValue EXPLOSIONS_START_FIRES; + private static final ConfigSpec.DoubleValue VEHICLE_DAMAGE_FACTOR; + private static final ConfigSpec.BooleanValue INFINITE_AMMO; + private static final ConfigSpec.StringListValue DISABLED_VEHICLES; + private static final ConfigSpec.StringListValue PACK_ALLOW_LIST; + private static final ConfigSpec.IntValue MAX_PACK_SIZE; + private static final ConfigSpec.IntValue MAX_TOTAL_SIZE; static { - ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + ConfigSpec.Builder builder = ConfigSpec.builder(); builder.push("explosions"); EXPLOSIONS_BREAK_BLOCKS = builder @@ -45,7 +48,7 @@ public final class ServerConfig { DISABLED_VEHICLES = builder .comment("Vehicles that cannot be placed, by definition name (the file name, such as \"ah-64\").", "Vehicles already in the world stay.") - .defineListAllowEmpty("disabled", List.of(), o -> o instanceof String); + .defineList("disabled", List.of()); builder.pop(); builder.push("weapons"); @@ -59,7 +62,7 @@ public final class ServerConfig { .push("packs"); PACK_ALLOW_LIST = builder .comment("Pack ids the server loads, such as \"rework-example\". Empty loads every pack in the folder.") - .defineListAllowEmpty("allowList", List.of(), o -> o instanceof String); + .defineList("allowList", List.of()); MAX_PACK_SIZE = builder .comment("Largest pack the server loads and sends to players, in megabytes.") .defineInRange("maxPackSize", 8, 1, 512); @@ -75,6 +78,21 @@ public final class ServerConfig { private ServerConfig() { } + /** Reads the world's file, writing it with the defaults if it is new. */ + public static void load(Path worldDir) { + Path file = worldDir.resolve("serverconfig").resolve(MCHeli.MOD_ID + "-server.toml"); + try { + SPEC.load(file); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not read {}; using the defaults", file, e); + } + } + + /** The world closed: back to the defaults. */ + public static void unload() { + SPEC.unload(); + } + public static boolean explosionsBreakBlocks() { return SPEC.isLoaded() ? EXPLOSIONS_BREAK_BLOCKS.get() : EXPLOSIONS_BREAK_BLOCKS.getDefault(); } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 0000000..ab62e12 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.client; + +import net.minecraft.client.Camera; + +/** + * The camera's angles for this frame, after vanilla placed the camera and before it renders, as + * the loader module hands them to {@link ClientHooks#computeCameraAngles}. Forge and NeoForge + * back this with their viewport event; Fabric with a mixin at the same spot. + */ +public interface CameraAngles { + Camera camera(); + + float partialTick(); + + float yaw(); + + float pitch(); + + float roll(); + + void setYaw(float yaw); + + void setPitch(float pitch); + + void setRoll(float roll); +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 0000000..6963150 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,215 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.hud.HudRenderer; +import com.jnhprojects.mcheli.client.hud.HudScripts; +import com.jnhprojects.mcheli.client.render.A10Renderer; +import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.PreparableReloadListener; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; + +import java.util.List; +import java.util.Map; + +/** + * Client side counterpart of {@code CommonHooks}: what the mod does on client events. The loader + * module calls these from its own events. Kept out of the shared entry point so dedicated servers + * never load these classes. + */ +public final class ClientHooks { + /** The weapon HUD's layer, drawn above the vanilla HUD. */ + public static final ResourceLocation HUD_LAYER = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "weapons"); + + private ClientHooks() { + } + + /** During mod construction, on a client only. */ + public static void init() { + ClientConfig.load(Platform.get().configDir()); + ClientAccess.install(() -> Minecraft.getInstance().player, ClientPacks.INSTANCE); + } + + /** How a loader registers an entity renderer. */ + public interface EntityRenderers { + void register(EntityType type, EntityRendererProvider provider); + } + + public static void registerEntityRenderers(EntityRenderers renderers) { + renderers.register(ModEntities.HELICOPTER.get(), AircraftRenderer::new); + renderers.register(ModEntities.PLANE.get(), AircraftRenderer::new); + renderers.register(ModEntities.TANK.get(), AircraftRenderer::new); + renderers.register(ModEntities.VEHICLE.get(), AircraftRenderer::new); + renderers.register(ModEntities.BULLET.get(), ProjectileRenderer::new); + renderers.register(ModEntities.ROCKET.get(), ProjectileRenderer::new); + renderers.register(ModEntities.BOMB.get(), ProjectileRenderer::new); + renderers.register(ModEntities.AA_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.AS_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.AT_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.TV_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.TORPEDO.get(), ProjectileRenderer::new); + renderers.register(ModEntities.MARKER_ROCKET.get(), ProjectileRenderer::new); + renderers.register(ModEntities.DISPENSED_ITEM.get(), ProjectileRenderer::new); + renderers.register(ModEntities.FLARE.get(), FlareRenderer::new); + renderers.register(ModEntities.A10.get(), A10Renderer::new); + } + + public static List keyMappings() { + return KeyBindings.ALL; + } + + /** Client resource reload listeners, by the id Fabric wants for each. */ + public static Map reloadListeners() { + return Map.of(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "meshes"), MeshCache.INSTANCE, + ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "hud_scripts"), HudScripts.INSTANCE); + } + + public static void clientTickEnd() { + CrewController.onClientTick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + } + + /** Tilts the view with the hull and moves the eye into the cockpit or onto a TV missile. */ + public static void computeCameraAngles(CameraAngles angles) { + CrewController.onCameraAngles(angles); + } + + /** Sets the camera's rotation, roll included, the way Forge's {@code Camera#setRotation} does. */ + public interface CameraRotation { + void set(Camera camera, float yaw, float pitch, float roll); + } + + /** + * For loaders without Forge's camera angle event: called where Forge fires it, right after + * vanilla placed the camera for this frame. Starts from the camera's own angles and no roll, + * and only touches the rotation when something changed it. + */ + public static void computeCameraAngles(Camera camera, float partialTick, CameraRotation rotation) { + Angles angles = new Angles(camera, partialTick, camera.getYRot(), camera.getXRot()); + computeCameraAngles(angles); + if (angles.yaw != camera.getYRot() || angles.pitch != camera.getXRot() || angles.roll != 0.0F) { + rotation.set(camera, angles.yaw, angles.pitch, angles.roll); + } + } + + private static final class Angles implements CameraAngles { + private final Camera camera; + private final float partialTick; + private float yaw; + private float pitch; + private float roll; + + Angles(Camera camera, float partialTick, float yaw, float pitch) { + this.camera = camera; + this.partialTick = partialTick; + this.yaw = yaw; + this.pitch = pitch; + } + + @Override + public Camera camera() { + return camera; + } + + @Override + public float partialTick() { + return partialTick; + } + + @Override + public float yaw() { + return yaw; + } + + @Override + public float pitch() { + return pitch; + } + + @Override + public float roll() { + return roll; + } + + @Override + public void setYaw(float yaw) { + this.yaw = yaw; + } + + @Override + public void setPitch(float pitch) { + this.pitch = pitch; + } + + @Override + public void setRoll(float roll) { + this.roll = roll; + } + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand(); + } + + /** What happens to a press of the use key. */ + public enum UseKey { + /** Vanilla handles it. */ + PASS, + /** Taken, and the hand does not swing: it is a weapon trigger. */ + CONSUME, + /** Taken, and the hand swings as for a vanilla click: the player boards a vehicle. */ + CONSUME_AND_SWING + } + + public static UseKey onUseKey(InteractionHand hand) { + if (WeaponController.onUseItem()) { + return UseKey.CONSUME; + } + return VehiclePicker.onUseItem(hand) ? UseKey.CONSUME_AND_SWING : UseKey.PASS; + } + + /** The pick block key switches weapons in a seat that has any; true when it was taken. */ + public static boolean onPickBlockKey() { + return WeaponController.onPickBlock(); + } + + public static void beforeRenderLiving(LivingEntity entity, PoseStack pose, float partialTicks) { + RiderPose.beforeRender(entity, pose, partialTicks); + } + + public static void drawHud(GuiGraphics graphics, DeltaTracker delta) { + WeaponHud.draw(graphics, delta); + } + + /** Without the original zip there are no vehicles, so say so where the player will see it. */ + public static void loggedIn(LocalPlayer player) { + MCHeli.legacyContent().problem().ifPresent(player::sendSystemMessage); + } + + public static void loggedOut() { + ClientPacks.INSTANCE.loggedOut(); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java similarity index 98% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java index 6679322..13f2bad 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -13,7 +13,7 @@ import com.jnhprojects.mcheli.network.PackRequestPacket; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; -import net.minecraftforge.fml.loading.FMLPaths; +import com.jnhprojects.mcheli.platform.Platform; import org.jspecify.annotations.Nullable; import java.io.IOException; @@ -52,7 +52,7 @@ final class ClientPacks implements ClientAccess.PackReceiver { private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); - private final Path cache = FMLPaths.GAMEDIR.get().resolve(MCHeli.MOD_ID).resolve("cache"); + private final Path cache = Platform.get().gameDir().resolve(MCHeli.MOD_ID).resolve("cache"); private List wanted = List.of(); private final Map downloads = new LinkedHashMap<>(); /** Packs this class opened for the current server; closed when the next list replaces them. */ diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/CrewController.java new file mode 100644 index 0000000..18c514c --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -0,0 +1,444 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.hud.HudRenderer; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.CameraPosition; +import com.jnhprojects.mcheli.content.aircraft.SeatDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PilotStatePacket; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.Input; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; + +/** + * Turns local player input into vehicle control, for whichever seat the player occupies. + * + *

Keys are sampled once per client tick. The mouse is handled every frame, the way the + * original did it: for helicopters and planes, vanilla first turns the player's view, then the + * change is fed to the aircraft and the view is snapped back onto the aircraft's nose. Everyone + * else looks around freely, within the limits of their seat, and turrets follow their view. + * + *

Gunner seats and camera positions move the first person eye to the point the definition + * gives, which is how the original's camera entity behaved. Every other seat keeps its eye above + * the seat along the hull's up axis, so it tilts with the vehicle. + */ +final class CrewController { + /** Two presses of jump within this many ticks eject the player. */ + private static final int EJECT_WINDOW = 10; + + private static boolean jumpWasDown; + private static int lastJumpPress = -100; + private static int ticks; + private static boolean tracking; + private static float lastYaw; + private static float lastPitch; + + private static @Nullable AircraftEntity viewVehicle; + private static int viewSeat = -1; + private static int cameraIndex; + private static boolean gunnerSight; + + private static @Nullable MethodHandle setCameraPosition; + private static boolean cameraLookupFailed; + + private CrewController() { + } + + /** + * Like the original, the first person hand stays hidden in a vehicle: always for the pilot, + * and for anyone else who looks through a camera or has a weapon to aim. + */ + static boolean hidesHand() { + LocalPlayer player = Minecraft.getInstance().player; + AircraftEntity vehicle = vehicleOf(player); + if (vehicle == null) { + return false; + } + AircraftDefinition def = vehicle.definition(); + int seat = vehicle.seatOf(player); + if (seat < 0 || seat >= def.seatCount()) { + return false; + } + VehicleWeapons weapons = vehicle.weapons(); + boolean armed = weapons != null && weapons.selected(seat) >= 0; + return seat == 0 || armed || tvCamera(vehicle, seat) != null || eyePosition(vehicle, def, seat) != null; + } + + private static @Nullable AircraftEntity vehicleOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof AircraftEntity vehicle && vehicle.definition() != null ? vehicle : null; + } + + static void onClientTick() { + ticks++; + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + AircraftEntity vehicle = vehicleOf(player); + if (vehicle == null) { + jumpWasDown = false; + tracking = false; + viewVehicle = null; + viewSeat = -1; + return; + } + int seat = vehicle.seatOf(player); + if (vehicle != viewVehicle || seat != viewSeat) { + // New vehicle or seat: start from the default view. + viewVehicle = vehicle; + viewSeat = seat; + cameraIndex = 0; + gunnerSight = false; + tracking = false; + } + + Input keys = player.input; + boolean jumpPressed = keys.jumping && !jumpWasDown; + jumpWasDown = keys.jumping; + boolean pilot = seat == 0 && vehicle.getControllingPassenger() == player; + + if (pilot) { + AircraftEntity.ControlInput input = vehicle.input(); + input.throttleUp = keys.up; + input.throttleDown = keys.down; + input.left = keys.left; + input.right = keys.right; + input.freeLook = isFreeLook(vehicle); + input.brake = vehicle instanceof TankEntity && keys.jumping; + if (input.brake) { + // The hand brake also stops pivot turns once the vehicle has come to rest. + input.throttleUp = false; + input.throttleDown = false; + double dx = vehicle.getX() - vehicle.xo; + double dz = vehicle.getZ() - vehicle.zo; + if (vehicle.visibleThrottle() <= 0.03F && dx * dx + dz * dz < 0.01) { + input.left = false; + input.right = false; + } + } + if (jumpPressed && vehicle instanceof HelicopterEntity heli) { + heli.toggleHovering(); + } + ModNetwork.sendToServer(new PilotStatePacket(vehicle.getId(), vehicle.visibleThrottle(), vehicle.reverse(), + vehicle.roll(), vehicle.isHoveringMode(), input.write())); + } + + // Eject with a double tap, except for helicopter pilots whose jump key toggles hover. + if (jumpPressed && vehicle.canEject(player) && !(pilot && vehicle instanceof HelicopterEntity)) { + if (ticks - lastJumpPress <= EJECT_WINDOW) { + send(vehicle, CrewActionPacket.Action.EJECT); + lastJumpPress = -100; + } else { + lastJumpPress = ticks; + } + } + + while (KeyBindings.SWITCH_SEAT.consumeClick()) { + send(vehicle, CrewActionPacket.Action.SWITCH_SEAT); + } + while (KeyBindings.SWITCH_CAMERA.consumeClick()) { + switchCamera(vehicle, seat); + } + while (KeyBindings.LANDING_GEAR.consumeClick()) { + if (pilot) { + send(vehicle, CrewActionPacket.Action.LANDING_GEAR); + } + } + while (KeyBindings.FOLD.consumeClick()) { + if (pilot) { + send(vehicle, CrewActionPacket.Action.FOLD); + } + } + while (KeyBindings.VTOL.consumeClick()) { + if (pilot) { + send(vehicle, CrewActionPacket.Action.VTOL); + } + } + while (KeyBindings.RACK_LOAD.consumeClick()) { + if (pilot) { + send(vehicle, CrewActionPacket.Action.RACK_LOAD); + } + } + while (KeyBindings.RACK_UNLOAD.consumeClick()) { + if (pilot) { + send(vehicle, CrewActionPacket.Action.RACK_UNLOAD); + } + } + } + + private static void send(AircraftEntity vehicle, CrewActionPacket.Action action) { + ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), action)); + } + + /** Pilot cycles the camera positions; gunners that may leave their sight toggle it. */ + private static void switchCamera(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount()) { + return; + } + SeatDefinition s = def.seats.get(seat); + if (seat == 0) { + switchPilotView(vehicle, def); + } else if (s.gunner() && s.switchGunner()) { + gunnerSight = !gunnerSight; + } + } + + /** + * The pilot's switch mode key, after {@code KeySwitchMode}: leave gunner mode, otherwise step + * through the extra camera positions, otherwise enter gunner mode where the vehicle allows it, + * otherwise go to the first extra camera. Unlike the original, leaving gunner mode never + * depends on the plane still meeting the conditions for entering it. + */ + private static void switchPilotView(AircraftEntity vehicle, AircraftDefinition def) { + int cameras = def.cameraPositions.size(); + if (vehicle.isGunnerMode()) { + send(vehicle, CrewActionPacket.Action.GUNNER_MODE); + cameraIndex = 0; + } else if (cameraIndex > 0) { + cameraIndex = (cameraIndex + 1) % cameras; + } else if (vehicle.canSwitchGunnerMode()) { + send(vehicle, CrewActionPacket.Action.GUNNER_MODE); + } else if (cameras >= 2) { + cameraIndex = 1; + } + } + + /** + * Free look for the pilot. Helicopters and planes look around while the key is held; tanks + * start from their {@code DefaultFreelook} setting and the key swaps it while held. + */ + private static boolean isFreeLook(AircraftEntity vehicle) { + boolean held = KeyBindings.FREE_LOOK.isDown(); + if (vehicle instanceof TankEntity) { + AircraftDefinition def = vehicle.definition(); + return def != null && def.defaultFreelook != held; + } + return held; + } + + static void onCameraAngles(CameraAngles event) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + AircraftEntity vehicle = vehicleOf(player); + if (vehicle == null || mc.isPaused()) { + return; + } + AircraftDefinition def = vehicle.definition(); + int seat = vehicle.seatOf(player); + if (seat < 0 || seat >= def.seatCount()) { + return; + } + float partialTicks = event.partialTick(); + boolean firstPerson = mc.options.getCameraType().isFirstPerson(); + + if (seat == 0 && vehicle.steersWithMouse() && !vehicle.isGunnerMode() && vehicle.getControllingPassenger() == player) { + steerWithMouse(vehicle, player, event, partialTicks, firstPerson); + } else { + tracking = false; + constrainView(vehicle, def, def.seats.get(seat), seat, player); + } + + TvMissileEntity missile = tvCamera(vehicle, seat); + if (!(firstPerson && missile != null)) { + // Every seat of every kind tilts with the hull, measured along the way the rider looks. + event.setRoll(AircraftRotation.cameraRoll(vehicle.getYRot(partialTicks), vehicle.getXRot(partialTicks), + vehicle.roll(partialTicks), event.yaw(), event.pitch())); + } + if (firstPerson && missile != null) { + placeCamera(event.camera(), missile.getPosition(partialTicks)); + } else if (firstPerson) { + Vector3 eye = eyePosition(vehicle, def, seat); + moveCamera(event.camera(), vehicle, def, eye != null ? eye : seatEye(vehicle, def, seat, player), seat, partialTicks); + } + } + + /** + * The normal eye, in hull space. Vanilla puts it straight above the rider in world space, + * which leaves the head outside the cockpit once the hull banks or pitches; measured along the + * hull's own up axis it stays where the rider model's head is drawn. + */ + private static Vector3 seatEye(AircraftEntity vehicle, AircraftDefinition def, int seat, LocalPlayer player) { + Vector3 pos = def.seats.get(seat).position(); + double up = player.getEyeHeight() - player.getVehicleAttachmentPoint(vehicle).y; + return new Vector3(pos.x(), pos.y() + up, pos.z()); + } + + /** The phase one pilot model: mouse movement becomes aircraft rotation, the view stays on the nose. */ + private static void steerWithMouse(AircraftEntity vehicle, LocalPlayer player, CameraAngles event, + float partialTicks, boolean firstPerson) { + boolean freeLook = KeyBindings.FREE_LOOK.isDown(); + boolean ownPitch = vehicle.isHovering(); + if (!freeLook) { + if (tracking) { + float dYaw = Mth.wrapDegrees(player.getYRot() - lastYaw); + float dPitch = player.getXRot() - lastPitch; + vehicle.applyPilotLook(dYaw, dPitch); + HudRenderer.onPilotLook(dYaw, dPitch); + } + lockView(player, vehicle.getYRot(), vehicle.yRotO, ownPitch ? null : vehicle.getXRot(), vehicle.xRotO); + lastYaw = player.getYRot(); + lastPitch = player.getXRot(); + tracking = true; + } else { + // Re-acquire the nose smoothly once free look is released. + tracking = false; + } + if (firstPerson && !freeLook) { + event.setYaw(vehicle.getYRot(partialTicks)); + if (!ownPitch) { + event.setPitch(vehicle.getXRot(partialTicks)); + } + } + } + + /** Seat limits for everyone who does not fly with the mouse. */ + private static void constrainView(AircraftEntity vehicle, AircraftDefinition def, SeatDefinition seat, int index, LocalPlayer player) { + boolean freeLookHeld = KeyBindings.FREE_LOOK.isDown(); + if (seat.fixedRotation() && !freeLookHeld) { + lockView(player, vehicle.getYRot() + seat.fixedYaw(), vehicle.yRotO + seat.fixedYaw(), + vehicle.getXRot() + seat.fixedPitch(), vehicle.xRotO + seat.fixedPitch()); + return; + } + boolean pilot = index == 0; + if (pilot && vehicle instanceof TankEntity && !isFreeLook(vehicle)) { + lockView(player, vehicle.getYRot(), vehicle.yRotO, vehicle.getXRot(), vehicle.xRotO); + return; + } + float min; + float max; + if (pilot && (vehicle instanceof TankEntity || vehicle instanceof GroundVehicleEntity)) { + min = def.minRotationPitch; + max = def.maxRotationPitch; + } else if (seat.gunner() || (pilot && vehicle.isGunnerMode())) { + min = seat.minPitch(); + max = seat.maxPitch(); + } else { + return; + } + // Limits are relative to the hull's tilt along the view direction. + double rel = Math.toRadians(Mth.wrapDegrees(player.getYRot() - vehicle.getYRot())); + float tilt = (float) (vehicle.getXRot() * Math.cos(rel) + vehicle.roll() * Math.sin(rel)); + float pitch = Mth.clamp(player.getXRot(), tilt + min, tilt + max); + pitch = Mth.clamp(pitch, -90.0F, 90.0F); + if (pitch != player.getXRot()) { + player.setXRot(pitch); + player.xRotO = pitch; + } + } + + private static void lockView(LocalPlayer player, float yaw, float yawO, @Nullable Float pitch, float pitchO) { + player.setYRot(yaw); + player.yRotO = yawO; + player.setYHeadRot(yaw); + player.setYBodyRot(yaw); + if (pitch != null) { + player.setXRot(pitch); + player.xRotO = pitchO; + } + } + + /** + * Where the first person eye goes, in hull space, or {@code null} for the normal eye. Mirrors + * the original's choice between the pilot's camera positions and a gunner seat's sight. + */ + private static @Nullable Vector3 eyePosition(AircraftEntity vehicle, AircraftDefinition def, int seat) { + SeatDefinition s = def.seats.get(seat); + boolean sight = s.gunner() && (!s.switchGunner() || gunnerSight); + CameraPosition camera = null; + if (seat == 0) { + if (cameraIndex > 0 && cameraIndex < def.cameraPositions.size()) { + camera = def.cameraPositions.get(cameraIndex); + } else if (def.uav || def.alwaysCameraView || sight || vehicle.isGunnerMode() || vehicle instanceof GroundVehicleEntity) { + camera = def.cameraPositions.getFirst(); + } + } else if (sight) { + camera = s.camera() != null && s.camera() != CameraPosition.DEFAULT ? s.camera() : def.cameraPositions.getFirst(); + } + // A camera at the origin means the file never set one; the normal eye is better there. + if (camera == null || camera.position().equals(Vector3.ZERO)) { + return null; + } + return camera.position(); + } + + private static void moveCamera(Camera camera, AircraftEntity vehicle, AircraftDefinition def, Vector3 eye, int seat, float partialTicks) { + MethodHandle setter = cameraSetter(); + if (setter == null) { + return; + } + SeatDefinition s = def.seats.get(seat); + Vector3 local = s.rotatingSeat() ? vehicle.onTurret(def, eye, partialTicks) : eye; + Vector3f offset = AircraftRotation.toWorld(vehicle.getYRot(partialTicks), vehicle.getXRot(partialTicks), vehicle.roll(partialTicks), + local.x(), local.y(), local.z()); + placeCamera(camera, new Vec3(Mth.lerp(partialTicks, vehicle.xo, vehicle.getX()) + offset.x, + Mth.lerp(partialTicks, vehicle.yo, vehicle.getY()) + offset.y, + Mth.lerp(partialTicks, vehicle.zo, vehicle.getZ()) + offset.z)); + } + + /** + * The TV missile this seat is flying, whose nose camera replaces the eye. Like the original, + * only while the seat has a TV missile selected in its guided mode. + */ + static @Nullable TvMissileEntity tvCamera(AircraftEntity vehicle, int seat) { + TvMissileEntity missile = vehicle.tvMissile(); + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.current(seat) : null; + if (missile == null || set == null || set.definition().type != WeaponType.TV_MISSILE || set.mode() != 0) { + return null; + } + return missile; + } + + private static void placeCamera(Camera camera, Vec3 at) { + MethodHandle setter = cameraSetter(); + if (setter == null) { + return; + } + try { + setter.invoke(camera, at); + } catch (Throwable t) { + MCHeli.LOGGER.warn("Could not move the camera; the first person eye stays where vanilla puts it", t); + setCameraPosition = null; + cameraLookupFailed = true; + } + } + + private static Method accessible(Method method) { + method.setAccessible(true); + return method; + } + + /** {@code Camera.setPosition} is protected. Looked up once; on failure the feature turns itself off. */ + private static @Nullable MethodHandle cameraSetter() { + if (setCameraPosition == null && !cameraLookupFailed) { + try { + setCameraPosition = MethodHandles.lookup().unreflect( + accessible(Camera.class.getDeclaredMethod("setPosition", Vec3.class))); + } catch (Exception e) { + MCHeli.LOGGER.warn("Camera.setPosition is not accessible; the first person eye stays where vanilla puts it", e); + cameraLookupFailed = true; + } + } + return setCameraPosition; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java new file mode 100644 index 0000000..863771b --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.KeyMapping; + +import java.util.List; + +/** + * Mod key bindings. Throttle, steering, hover, brake and eject reuse the vanilla movement and jump + * keys (W/S, A/D and Space by default), which matches the original defaults. Firing uses the + * vanilla use key and the pick block button switches weapons, the right and middle mouse buttons + * of the original. The rest keep the original's letters where vanilla leaves them free. + */ +public final class KeyBindings { + public static final String CATEGORY = "key.category." + MCHeli.MOD_ID + ".main"; + + /** Hold to look around without steering. The original used Left Ctrl, which now clashes with sprint. */ + public static final KeyMapping FREE_LOOK = new KeyMapping("key.mcheli.free_look", InputConstants.KEY_LALT, CATEGORY); + /** Cycles the pilot's camera positions, or toggles the gunner sight in seats that allow it. */ + public static final KeyMapping SWITCH_CAMERA = new KeyMapping("key.mcheli.switch_camera", InputConstants.KEY_H, CATEGORY); + public static final KeyMapping LANDING_GEAR = new KeyMapping("key.mcheli.landing_gear", InputConstants.KEY_B, CATEGORY); + /** Folds hatches and wings, sweeps variable geometry wings. */ + public static final KeyMapping FOLD = new KeyMapping("key.mcheli.fold", InputConstants.KEY_Z, CATEGORY); + /** The original used F, which vanilla now uses to swap hands. */ + public static final KeyMapping VTOL = new KeyMapping("key.mcheli.vtol", InputConstants.KEY_V, CATEGORY); + public static final KeyMapping SWITCH_SEAT = new KeyMapping("key.mcheli.switch_seat", InputConstants.KEY_R, CATEGORY); + public static final KeyMapping RACK_LOAD = new KeyMapping("key.mcheli.rack_load", InputConstants.KEY_J, CATEGORY); + public static final KeyMapping RACK_UNLOAD = new KeyMapping("key.mcheli.rack_unload", InputConstants.KEY_U, CATEGORY); + /** The second weapon switch key of the original; the first is the vanilla pick block button. */ + public static final KeyMapping SWITCH_WEAPON = new KeyMapping("key.mcheli.switch_weapon", InputConstants.KEY_G, CATEGORY); + public static final KeyMapping WEAPON_MODE = new KeyMapping("key.mcheli.weapon_mode", InputConstants.KEY_X, CATEGORY); + /** The original used V, which VTOL took over. */ + public static final KeyMapping FLARE = new KeyMapping("key.mcheli.flare", InputConstants.KEY_C, CATEGORY); + /** The original resupplied from the vehicle's inventory screen, which does not exist yet. */ + public static final KeyMapping RESUPPLY = new KeyMapping("key.mcheli.resupply", InputConstants.KEY_N, CATEGORY); + + public static final List ALL = List.of(FREE_LOOK, SWITCH_CAMERA, LANDING_GEAR, FOLD, VTOL, SWITCH_SEAT, RACK_LOAD, RACK_UNLOAD, + SWITCH_WEAPON, WEAPON_MODE, FLARE, RESUPPLY); + + private KeyBindings() { + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 0000000..43dcb6d --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,141 @@ +package com.jnhprojects.mcheli.client; + +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Vehicle textures, models and sounds that can change while the game runs, without a resource + * reload (F3 + T), which would stall the game for every pack loaded and is not available on a + * server at all. + * + *

Lookups try the resource manager first, so resource packs keep the last word, then the + * runtime roots: folders laid out like {@code assets/mcheli} that content can add at any time. + * Content packs and, later, the workshop register their folders here. + *

    + *
  • Textures found only in a runtime root are uploaded as a {@link DynamicTexture} the first + * time a vehicle draws them. {@link #refreshTextures} uploads every vehicle texture again + * from disk, which is how a content reload shows edited textures.
  • + *
  • Models are read through {@link #open} by the mesh cache, which a reload clears.
  • + *
  • Sounds found only in a runtime root are streamed from the file + * ({@link #soundFile}); see {@code RuntimeSoundInstance}.
  • + *
+ */ +public final class RuntimeAssets { + private static final Logger LOGGER = LoggerFactory.getLogger(RuntimeAssets.class); + + /** Searched in order, so the pack loaded last comes first. Replaced as a whole. */ + private static volatile List roots = List.of(); + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + private RuntimeAssets() { + } + + /** + * Sets the folders laid out like {@code assets/mcheli} that are searched after the resource + * packs, the first one first. Content packs set theirs whenever the packs in use change. + */ + public static void setRoots(List folders) { + roots = List.copyOf(folders); + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(ResourceLocation id) throws IOException { + Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); + if (resource.isPresent()) { + return resource.get().open(); + } + Path file = find(id.getPath()); + return file != null ? Files.newInputStream(file) : null; + } + + /** + * A texture ready to bind. The first request for a texture no resource pack has, but a runtime + * root does, uploads it. Cheap after the first call, so renderers can call it every frame. + */ + public static ResourceLocation texture(ResourceLocation id) { + TEXTURES.computeIfAbsent(id, key -> { + if (Minecraft.getInstance().getResourceManager().getResource(key).isPresent()) { + return false; + } + return upload(key); + }); + return id; + } + + /** + * Uploads every texture vehicles have drawn so far again, read fresh from disk. Resource pack + * textures are replaced too, until the next resource reload loads them the normal way. One that + * no longer exists anywhere, because its pack is gone, is released. + */ + public static void refreshTextures() { + for (Map.Entry entry : TEXTURES.entrySet()) { + boolean ours = upload(entry.getKey()); + if (!ours && entry.getValue()) { + Minecraft.getInstance().getTextureManager().release(entry.getKey()); + } + entry.setValue(ours); + } + } + + private static boolean upload(ResourceLocation id) { + try (InputStream in = open(id)) { + if (in == null) { + return false; + } + NativeImage image = NativeImage.read(in); + Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(image)); + return true; + } catch (IOException e) { + LOGGER.warn("Could not load texture {}", id, e); + return false; + } + } + + /** + * Drops the textures we uploaded, so the texture manager loads them again the normal way. + * Called on resource reload. + */ + public static void clearTextures() { + TEXTURES.forEach((id, ours) -> { + if (ours) { + Minecraft.getInstance().getTextureManager().release(id); + } + }); + TEXTURES.clear(); + } + + /** + * The OGG file for a sound event of ours that no resource pack defines, such as {@code heli} + * from {@code sounds/heli.ogg} in a runtime root. Numbered variants ({@code heli1.ogg}) count. + */ + public static @Nullable Path soundFile(String event) { + Path file = find("sounds/" + event + ".ogg"); + return file != null ? file : find("sounds/" + event + "1.ogg"); + } + + private static @Nullable Path find(String path) { + for (Path root : roots) { + Path file = root.resolve(path).normalize(); + if (file.startsWith(root) && Files.isRegularFile(file)) { + return file; + } + } + return null; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java similarity index 87% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java index cbb2119..9507412 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java @@ -11,7 +11,6 @@ import net.minecraft.world.phys.EntityHitResult; import net.minecraft.world.phys.HitResult; import net.minecraft.world.phys.Vec3; -import net.minecraftforge.client.event.InputEvent; import org.jspecify.annotations.Nullable; import java.util.Optional; @@ -28,14 +27,10 @@ final class VehiclePicker { private VehiclePicker() { } - static void init() { - InputEvent.InteractionKeyMappingTriggered.UseItem.BUS.addListener(VehiclePicker::onUseItem); - } - - private static boolean onUseItem(InputEvent.InteractionKeyMappingTriggered.UseItem event) { + static boolean onUseItem(InteractionHand hand) { Minecraft mc = Minecraft.getInstance(); LocalPlayer player = mc.player; - if (player == null || event.getHand() != InteractionHand.MAIN_HAND || player.getVehicle() instanceof AircraftEntity) { + if (player == null || hand != InteractionHand.MAIN_HAND || player.getVehicle() instanceof AircraftEntity) { return false; } // Vanilla already handles a click on the main box. diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java new file mode 100644 index 0000000..f5e6111 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -0,0 +1,190 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.FireWeaponPacket; +import com.jnhprojects.mcheli.network.LockWarningPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +import com.jnhprojects.mcheli.weapon.Shot; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The crew member's trigger: fires, switches weapons and modes, drops flares and resupplies. + * + *

Like the original, the client fires first. Its copy of the weapon decides whether the shot + * can happen (ready, loaded, locked on, a target in sight) and works out the option values; only + * then is the server asked, which fires the real projectile. + */ +final class WeaponController { + private WeaponController() { + } + + private record Seat(AircraftEntity vehicle, VehicleWeapons weapons, int seat) { + } + + private static @Nullable Seat seat(@Nullable LocalPlayer player) { + if (player == null || !(player.getVehicle() instanceof AircraftEntity vehicle) || vehicle.definition() == null) { + return null; + } + VehicleWeapons weapons = vehicle.weapons(); + int seat = vehicle.seatOf(player); + if (weapons == null || seat < 0 || seat >= vehicle.definition().seatCount()) { + return null; + } + return new Seat(vehicle, weapons, seat); + } + + /** The use key is the trigger while the seat has a weapon, so it must not also use items. */ + static boolean onUseItem() { + Seat s = seat(Minecraft.getInstance().player); + return s != null && s.weapons.selected(s.seat) >= 0; + } + + static boolean onPickBlock() { + Seat s = seat(Minecraft.getInstance().player); + if (s == null || s.weapons.sets().isEmpty()) { + return false; + } + send(s.vehicle, Minecraft.getInstance().player != null && Minecraft.getInstance().player.isShiftKeyDown() + ? CrewActionPacket.Action.PREVIOUS_WEAPON : CrewActionPacket.Action.NEXT_WEAPON); + click(); + return true; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + Seat s = seat(player); + if (s == null) { + return; + } + while (KeyBindings.SWITCH_WEAPON.consumeClick()) { + send(s.vehicle, player.isShiftKeyDown() ? CrewActionPacket.Action.PREVIOUS_WEAPON : CrewActionPacket.Action.NEXT_WEAPON); + click(); + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + WeaponSet set = s.weapons.current(s.seat); + if (set != null && set.switchMode()) { + send(s.vehicle, CrewActionPacket.Action.WEAPON_MODE); + click(); + } + } + while (KeyBindings.FLARE.consumeClick()) { + if (s.seat <= 1 && s.weapons.flares.use()) { + send(s.vehicle, CrewActionPacket.Action.FLARE); + click(); + } + } + while (KeyBindings.RESUPPLY.consumeClick()) { + send(s.vehicle, CrewActionPacket.Action.RESUPPLY); + } + + if (mc.screen == null && mc.options.keyUse.isDown() && !s.vehicle.isDestroyed()) { + fire(s, player); + } + } + + private static void fire(Seat s, LocalPlayer player) { + int id = s.weapons.selected(s.seat); + if (id < 0 || s.weapons.operatorSeat(id) != s.seat) { + return; + } + Shot shot = new Shot(s.vehicle, player); + boolean fired = s.weapons.use(player, s.seat, shot, (set, mount, sh) -> clientShot(s, id, set, sh, player)); + if (fired) { + ModNetwork.sendToServer(new FireWeaponPacket(s.vehicle.getId(), id, shot.option1, shot.option2)); + } + } + + /** The client half of each weapon's {@code shot}: can it fire, and with which options. */ + private static boolean clientShot(Seat s, int id, WeaponSet set, Shot shot, LocalPlayer player) { + WeaponDefinition w = set.definition(); + return switch (w.type) { + case MACHINE_GUN_2, ROCKET, MARKER_ROCKET -> { + shot.option1 = set.mode(); + yield true; + } + case AA_MISSILE, AT_MISSILE -> { + Guidance g = s.weapons.guidance(id); + if (g == null) { + yield false; + } + boolean locked = g.lock(player, viewPoint(player)); + if (g.shouldNotifyTarget() && g.target() != null) { + ModNetwork.sendToServer(new LockWarningPacket(g.target().getId())); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (!locked || g.lastLocked() == null) { + yield false; + } + shot.option1 = g.lastLocked().getId(); + shot.option2 = set.mode(); + yield true; + } + case TV_MISSILE -> { + shot.option1 = set.mode(); + shot.option2 = 0; + yield true; + } + case AS_MISSILE -> landAhead(s.vehicle, player, 1.62, 200.0, false) != null; + case CAS -> landAhead(s.vehicle, player, 2.0, 80.0, false) != null; + case TORPEDO -> !w.guidedTorpedo || landAhead(player, player, 0.0, 100.0, true) != null; + case DUMMY, SMOKE -> false; + case null -> false; + default -> true; + }; + } + + /** + * Where the player sees from: in first person the camera, which CrewController moves to gunner + * sights and launcher cameras; otherwise the player's eyes. + */ + private static Vec3 viewPoint(LocalPlayer player) { + Minecraft mc = Minecraft.getInstance(); + return mc.options.getCameraType().isFirstPerson() ? mc.gameRenderer.getMainCamera().getPosition() : player.getEyePosition(); + } + + /** A block along the player's view: dry ground for strike weapons, water for guided torpedoes. */ + private static @Nullable BlockHitResult landAhead(Entity origin, LocalPlayer player, double eyeHeight, double range, boolean water) { + Vec3 from = origin.position().add(0.0, eyeHeight, 0.0); + Vec3 to = from.add(Vec3.directionFromRotation(player.getXRot(), player.getYRot()).scale(range)); + BlockHitResult hit = player.level().clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, player)); + if (hit.getType() != HitResult.Type.BLOCK) { + return null; + } + boolean wet = false; + BlockPos pos = hit.getBlockPos(); + for (Direction d : Direction.values()) { + wet |= player.level().getFluidState(pos.relative(d)).is(FluidTags.WATER); + } + return wet == water ? hit : null; + } + + private static void send(AircraftEntity vehicle, CrewActionPacket.Action action) { + ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), action)); + } + + private static void click() { + LocalPlayer player = Minecraft.getInstance().player; + if (player != null) { + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java new file mode 100644 index 0000000..5b9c40c --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java @@ -0,0 +1,123 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.client.hud.HudRenderer; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.weapon.Guidance; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.chat.Component; + +/** + * The vehicle HUD layer. In first person it runs the seat's HUD script (the {@code HUD} line of + * the definition, or the kind's default), or {@code tv_missile} while flying a TV missile, as the + * original did; a seat whose HUD is {@code none} gets nothing. Where no script draws (third + * person, a destroyed vehicle, a script that does not exist) it shows a plain text readout of hit + * points, ammunition, reload, heat, lock and flares. + */ +final class WeaponHud { + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int RED = 0xFFFF5050; + private static final int GREEN = 0xFF60FF60; + private static final String NO_HUD = "none"; + + private WeaponHud() { + } + + /** A pilot in gunner mode gets the gunner seat's HUD, as in the original. */ + private static int hudSeat(AircraftEntity vehicle, int seat) { + return seat == 0 && vehicle.isGunnerMode() ? 1 : seat; + } + + static void draw(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (player == null || !(player.getVehicle() instanceof AircraftEntity vehicle)) { + return; + } + AircraftDefinition def = vehicle.definition(); + VehicleWeapons weapons = vehicle.weapons(); + int seat = vehicle.seatOf(player); + if (def == null || weapons == null || seat < 0 || seat >= def.seatCount()) { + return; + } + if (mc.options.getCameraType().isFirstPerson() && !vehicle.isDestroyed()) { + String hud = CrewController.tvCamera(vehicle, seat) != null ? "tv_missile" + : hudSeat(vehicle, seat) < def.huds.size() ? def.huds.get(hudSeat(vehicle, seat)) : null; + if (NO_HUD.equals(hud)) { + // The original's way of saying a seat has no HUD at all. + return; + } + if (hud != null && HudRenderer.draw(g, vehicle, def, player, seat, hud, delta.getGameTimeDeltaPartialTick(false))) { + return; + } + } + Font font = mc.font; + int x = g.guiWidth() - 160; + int y = g.guiHeight() - 92; + + float hp = (float) vehicle.hp() / def.maxHp; + int hpColor = vehicle.isDestroyed() ? RED : hp < 0.5F ? AMBER : GREEN; + g.drawString(font, Component.translatable("hud.mcheli.hp", vehicle.hp(), def.maxHp), x, y, hpColor); + y += 11; + + WeaponSet set = weapons.current(seat); + if (set != null) { + g.drawString(font, set.displayName(), x, y, WHITE); + y += 11; + Component ammo = vehicle.hasInfiniteAmmo(player) && set.magazineSize() > 0 + ? Component.translatable("hud.mcheli.ammo_infinite", set.ammo()) + : set.magazineSize() > 0 + ? Component.translatable("hud.mcheli.ammo", set.ammo(), set.restAmmo()) + : Component.translatable("hud.mcheli.ammo_none"); + g.drawString(font, ammo, x, y, set.ammo() == 0 && set.magazineSize() > 0 ? RED : WHITE); + y += 11; + Component status = null; + int color = AMBER; + if (set.isReloading()) { + status = Component.translatable("hud.mcheli.reloading"); + } else if (set.isOverheated()) { + status = Component.translatable("hud.mcheli.overheat"); + color = RED; + } else if (set.isInPreparation()) { + status = Component.translatable("hud.mcheli.preparing"); + } else if (set.magazineSize() > 0 && set.ammo() == 0 && set.restAmmo() == 0) { + status = Component.translatable("hud.mcheli.resupply", KeyBindings.RESUPPLY.getTranslatedKeyMessage()); + color = RED; + } + Guidance lock = weapons.guidance(weapons.selected(seat)); + if (status == null && lock != null && lock.lockCount() > 0) { + status = lock.isLocked() + ? Component.translatable("hud.mcheli.locked") + : Component.translatable("hud.mcheli.locking", lock.lockCount() * 100 / Math.max(lock.lockCountMax(), 1)); + color = lock.isLocked() ? RED : AMBER; + } + if (status != null) { + g.drawString(font, status, x, y, color); + y += 11; + } + if (set.definition().maxHeatCount > 0) { + int width = 100; + int filled = Math.min(width, set.currentHeat * width / set.definition().maxHeatCount); + g.fill(x, y + 2, x + width, y + 5, 0x80000000); + g.fill(x, y + 2, x + filled, y + 5, set.isOverheated() ? RED : AMBER); + y += 8; + } + } else if (!weapons.sets().isEmpty()) { + g.drawString(font, Component.translatable("hud.mcheli.no_weapon"), x, y, WHITE); + y += 11; + } + if (seat <= 1 && weapons.flares.isFitted()) { + Component flare = vehicle.isFlareActive() ? Component.translatable("hud.mcheli.flare_active") + : weapons.flares.isPreparing() ? Component.translatable("hud.mcheli.flare_wait") + : Component.translatable("hud.mcheli.flare_ready", KeyBindings.FLARE.getTranslatedKeyMessage()); + g.drawString(font, flare, x, y, vehicle.isFlareActive() ? GREEN : WHITE); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java new file mode 100644 index 0000000..b752e0a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java @@ -0,0 +1,164 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.ResourceLocation; + +/** + * The drawing primitives of {@code MCH_HudItem}, on the 1.21.1 GUI. + * + *

The original drew GL lines, points and quads at fractional GUI coordinates. The GUI now only + * takes whole numbers, but every element keeps a copy of the pose, so each primitive is a unit + * square stretched and turned into place. Lines are one GUI unit thick, which is what + * {@code glLineWidth(scaleFactor)} gave; blips are two units square. + */ +final class HudCanvas { + private static final float LINE_WIDTH = 1.0F; + private static final float POINT_SIZE = 2.0F; + + private final GuiGraphics g; + private final Font font; + + HudCanvas(GuiGraphics g, Font font) { + this.g = g; + this.font = font; + } + + PoseStack pose() { + return g.pose(); + } + + /** Turns everything drawn until the matching {@code pop} by {@code degrees} about a point. */ + void pushRotation(double x, double y, float degrees) { + PoseStack pose = g.pose(); + pose.pushPose(); + pose.translate((float) x, (float) y, 0.0F); + pose.mulPose(Axis.ZP.rotation((float) Math.toRadians(degrees))); + pose.translate((float) -x, (float) -y, 0.0F); + } + + void pop() { + g.pose().popPose(); + } + + void line(double x0, double y0, double x1, double y1, int color) { + double dx = x1 - x0; + double dy = y1 - y0; + double length = Math.sqrt(dx * dx + dy * dy); + if (length < 1.0E-4) { + return; + } + PoseStack pose = g.pose(); + pose.pushPose(); + pose.translate((float) x0, (float) y0, 0.0F); + pose.mulPose(Axis.ZP.rotation((float) Math.atan2(dy, dx))); + pose.translate(0.0F, -LINE_WIDTH / 2.0F, 0.0F); + pose.scale((float) length, LINE_WIDTH, 1.0F); + g.fill(0, 0, 1, 1, color); + pose.popPose(); + } + + /** {@code GL_LINES}: x, y pairs taken two points at a time. */ + void lines(double[] p, int color) { + for (int i = 0; i + 3 < p.length; i += 4) { + line(p[i], p[i + 1], p[i + 2], p[i + 3], color); + } + } + + /** {@code GL_LINE_STRIP}. */ + void strip(double[] p, int color) { + for (int i = 0; i + 3 < p.length; i += 2) { + line(p[i], p[i + 1], p[i + 2], p[i + 3], color); + } + } + + /** {@code GL_LINE_LOOP}. */ + void loop(double[] p, int color) { + strip(p, color); + if (p.length >= 4) { + line(p[p.length - 2], p[p.length - 1], p[0], p[1], color); + } + } + + /** + * {@code GL_LINES} with {@code glLineStipple(factor, pattern)}: each segment starts the 16 bit + * pattern afresh, lowest bit first, every bit covering {@code factor} GUI units. + */ + void stippled(double[] p, int color, int factor, int pattern) { + double step = Math.max(factor, 1); + for (int i = 0; i + 3 < p.length; i += 4) { + double dx = p[i + 2] - p[i]; + double dy = p[i + 3] - p[i + 1]; + double length = Math.sqrt(dx * dx + dy * dy); + if (length < 1.0E-4) { + continue; + } + double ux = dx / length; + double uy = dy / length; + double runStart = -1.0; + int bit = 0; + for (double t = 0.0; t < length; t += step, bit = (bit + 1) & 15) { + boolean on = (pattern >> bit & 1) != 0; + if (on && runStart < 0.0) { + runStart = t; + } else if (!on && runStart >= 0.0) { + line(p[i] + ux * runStart, p[i + 1] + uy * runStart, p[i] + ux * t, p[i + 1] + uy * t, color); + runStart = -1.0; + } + } + if (runStart >= 0.0) { + line(p[i] + ux * runStart, p[i + 1] + uy * runStart, p[i + 2], p[i + 3], color); + } + } + } + + void rect(double x0, double y0, double x1, double y1, int color) { + double left = Math.min(x0, x1); + double top = Math.min(y0, y1); + double width = Math.abs(x1 - x0); + double height = Math.abs(y1 - y0); + if (width <= 0.0 || height <= 0.0) { + return; + } + PoseStack pose = g.pose(); + pose.pushPose(); + pose.translate((float) left, (float) top, 0.0F); + pose.scale((float) width, (float) height, 1.0F); + g.fill(0, 0, 1, 1, color); + pose.popPose(); + } + + void point(double x, double y, int color) { + rect(x - POINT_SIZE / 2.0, y - POINT_SIZE / 2.0, x + POINT_SIZE / 2.0, y + POINT_SIZE / 2.0, color); + } + + /** + * {@code drawTexture}: texels {@code u, v, uWidth, vHeight} of a {@code textureWidth} by + * {@code textureHeight} image, drawn into a box and turned about its centre. Drawn untinted, + * as the original reset the colour to white first. + */ + void texture(ResourceLocation texture, double left, double top, double width, double height, double u, double v, + double uWidth, double vHeight, float rotation, int textureWidth, int textureHeight) { + if (width == 0.0 || height == 0.0) { + return; + } + PoseStack pose = g.pose(); + pose.pushPose(); + pose.translate((float) (left + width / 2.0), (float) (top + height / 2.0), 0.0F); + pose.mulPose(Axis.ZP.rotation((float) Math.toRadians(rotation))); + pose.translate((float) (-width / 2.0), (float) (-height / 2.0), 0.0F); + pose.scale((float) width, (float) height, 1.0F); + g.blit(texture, 0, 0, 1, 1, (float) u, (float) v, (int) Math.round(uWidth), (int) Math.round(vHeight), textureWidth, textureHeight); + pose.popPose(); + } + + void text(String text, int x, int y, int color, boolean centered) { + if (centered) { + g.drawCenteredString(font, text, x, y, color); + } else { + g.drawString(font, text, x, y, color); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java new file mode 100644 index 0000000..458ee88 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java @@ -0,0 +1,354 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.hud.Expression; +import com.jnhprojects.mcheli.hud.HudCommand; +import com.jnhprojects.mcheli.hud.HudScript; +import com.jnhprojects.mcheli.hud.HudVariable; +import com.jnhprojects.mcheli.hud.TextArg; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.IllegalFormatException; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Runs HUD scripts, the drawing half of {@code MCH_Hud} and the {@code MCH_HudItem} subclasses. + * + *

An {@code If} hides every line up to its {@code EndIf}; {@code Exit} ends the current script + * only, so a {@code Call}er carries on; the colour carries into called scripts and over to the next + * frame. A script cannot call itself, directly or through another, the same guard the original had. + * A line that fails to format is skipped and reported once, where the original crashed. + */ +public final class HudRenderer { + private static final Logger LOGGER = LoggerFactory.getLogger(HudRenderer.class); + private static final int AZIMUTH_RED = 0xFFFF0000; + private static final String[] AZIMUTH_8 = {"S", "SW", "W", "NW", "N", "NE", "E", "SE"}; + /** {@code glLineStipple} pattern of the original's dashed lines. */ + private static final int DASHES = 0xCCCC; + + private static final HudState STATE = new HudState(); + private static final Set REPORTED = new HashSet<>(); + + private final HudCanvas canvas; + private final AircraftEntity vehicle; + private final AircraftDefinition def; + private final LocalPlayer player; + private final double centerX; + private final double centerY; + private final Set running = new HashSet<>(); + + private HudRenderer(HudCanvas canvas, AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, double width, double height) { + this.canvas = canvas; + this.vehicle = vehicle; + this.def = def; + this.player = player; + this.centerX = width / 2.0; + this.centerY = height / 2.0; + } + + public static void tick() { + LocalPlayer player = Minecraft.getInstance().player; + STATE.tick(player != null && player.getVehicle() instanceof AircraftEntity vehicle ? vehicle : null); + } + + /** Fed by the pilot's mouse steering; moves the control stick indicator. */ + public static void onPilotLook(float dYaw, float dPitch) { + STATE.onPilotLook(dYaw, dPitch); + } + + /** + * Draws a HUD script for the local player's seat. + * + * @return false if no script by that name is loaded, so the caller can draw something else + */ + public static boolean draw(GuiGraphics g, AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, + int seat, String hud, float partialTicks) { + HudScript script = HudScripts.INSTANCE.get(hud); + if (script == null) { + return false; + } + double width = g.guiWidth(); + double height = g.guiHeight(); + STATE.update(vehicle, def, player, seat, width, height, partialTicks); + HudRenderer renderer = new HudRenderer(new HudCanvas(g, Minecraft.getInstance().font), vehicle, def, player, width, height); + renderer.run(script); + return true; + } + + private double eval(Expression e) { + return e.eval(STATE.vars); + } + + private double x(Expression e) { + return centerX + eval(e); + } + + private double y(Expression e) { + return centerY + eval(e); + } + + private double[] points(List coordinates) { + double[] out = new double[coordinates.size()]; + for (int i = 0; i + 1 < out.length; i += 2) { + out[i] = x(coordinates.get(i)); + out[i + 1] = y(coordinates.get(i + 1)); + } + return out; + } + + private void run(HudScript script) { + if (!running.add(script.name())) { + return; + } + try { + boolean hidden = false; + for (HudCommand command : script.commands()) { + if (command instanceof HudCommand.If c) { + hidden = eval(c.condition()) == 0.0; + continue; + } + if (command instanceof HudCommand.EndIf) { + hidden = false; + continue; + } + if (hidden) { + continue; + } + if (command instanceof HudCommand.Exit) { + return; + } + execute(script, command); + } + } finally { + running.remove(script.name()); + } + } + + private void execute(HudScript script, HudCommand command) { + int color = STATE.color; + switch (command) { + case HudCommand.Color c -> { + STATE.color = (int) (long) eval(c.argb()); + STATE.vars.set(HudVariable.COLOR, STATE.color & 0xFFFFFFFFL); + } + case HudCommand.Call c -> { + HudScript called = HudScripts.INSTANCE.get(c.hud()); + if (called != null) { + run(called); + } + } + case HudCommand.Text c -> text(script, c, color); + case HudCommand.Texture c -> { + ResourceLocation texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + c.name() + ".png"); + int[] size = HudScripts.INSTANCE.textureSize(Minecraft.getInstance().getResourceManager(), texture); + canvas.texture(texture, x(c.left()), y(c.top()), eval(c.width()), eval(c.height()), + eval(c.u()), eval(c.v()), eval(c.uWidth()), eval(c.vHeight()), (float) eval(c.rotation()), size[0], size[1]); + } + case HudCommand.Rect c -> { + // The original truncated the size, not the position. + double left = x(c.left()); + double top = y(c.top()); + canvas.rect(left, top, left + (int) eval(c.width()), top + (int) eval(c.height()), color); + } + case HudCommand.Line c -> canvas.strip(points(c.points()), color); + case HudCommand.LineStipple c -> canvas.stippled(points(c.points()), color, (int) eval(c.factor()), (int) eval(c.pattern())); + case HudCommand.Radar c -> radar(c, color); + case HudCommand.Graduation c -> graduation(c, color); + case HudCommand.CameraRot c -> cameraRot(x(c.x()), y(c.y()), color); + case HudCommand.If c -> { + } + case HudCommand.EndIf c -> { + } + case HudCommand.Exit c -> { + } + } + } + + /** {@code MCH_HudItemString}: skipped while a weapon value is shown and no weapon (or magazine) applies. */ + private void text(HudScript script, HudCommand.Text c, int color) { + Object[] args = new Object[c.args().size()]; + for (int i = 0; i < args.length; i++) { + TextArg arg = c.args().get(i); + if ((arg.needsWeapon() && !STATE.hasWeapon()) || (arg.needsMagazine() && !STATE.hasMagazine())) { + return; + } + args[i] = STATE.text(arg, vehicle, def, player); + } + String text; + try { + text = String.format(Locale.ROOT, c.format(), args); + } catch (IllegalFormatException e) { + String key = script.name() + ':' + c.line(); + if (REPORTED.add(key)) { + LOGGER.warn("HUD {}.txt line {}: cannot format \"{}\": {}", script.name(), c.line(), c.format(), e.getMessage()); + } + return; + } + canvas.text(text, (int) x(c.x()), (int) y(c.y()), color, c.centered()); + } + + /** {@code MCH_HudItemRadar}: blips at half scale for a 64 unit box, turned with the view. */ + private void radar(HudCommand.Radar c, int color) { + List blips = c.enemies() ? STATE.enemyBlips : STATE.entityBlips; + if (blips.isEmpty()) { + return; + } + double rotation = Math.toRadians(eval(c.rotation())); + double left = x(c.left()); + double top = y(c.top()); + double w = eval(c.width()); + double h = eval(c.height()); + double cos = Math.cos(rotation); + double sin = Math.sin(rotation); + for (double[] blip : blips) { + double px = blip[0] / 2.0 * w / 64.0; + double py = blip[1] / 2.0 * h / 64.0; + double rx = px * cos - py * sin; + double ry = px * sin + py * cos; + if (rx > -w / 2.0 && rx < w / 2.0 && ry > -h / 2.0 && ry < h / 2.0) { + canvas.point(rx + left + w / 2.0, ry + top + h / 2.0, color); + } + } + } + + private void graduation(HudCommand.Graduation c, int color) { + int x = (int) x(c.x()); + int y = (int) y(c.y()); + double angle = eval(c.angle()); + canvas.pushRotation(x, y, (float) eval(c.roll())); + switch (c.kind()) { + case YAW -> headingTape(angle, color, x, y); + case PITCH_1 -> pitchScale(angle, color, x, y); + case PITCH_2 -> pitchLadder(angle, color, x, y, true); + case PITCH_3 -> pitchLadder(angle, color, x, y, false); + } + canvas.pop(); + } + + /** {@code drawCommonGraduationYaw}: a tick every degree, labels every three, compass points every 45. */ + private void headingTape(double playerYaw, int color, int posX, int posY) { + double yaw = HudState.rotate360(playerYaw); + posX -= 90; + double[] line = new double[76]; + int offset = (int) (yaw * 10.0) % 10; + int degree = (int) yaw - 9; + for (int i = 0; i < line.length / 4; i++, degree++) { + int azimuthX = posX + i * 10 - offset; + line[i * 4] = azimuthX; + line[i * 4 + 1] = posY; + line[i * 4 + 2] = azimuthX; + line[i * 4 + 3] = posY + (degree % 45 == 0 ? 15 : degree % 3 == 0 ? 10 : 5); + if (degree % 45 == 0) { + canvas.text(AZIMUTH_8[Math.floorMod(degree, 360) / 45], azimuthX, posY - 10, AZIMUTH_RED, true); + } else if (degree % 3 == 0) { + int heading = degree + 180; + if (heading < 0) { + heading += 360; + } + if (heading > 360) { + heading -= 360; + } + canvas.text(Integer.toString(heading), azimuthX, posY - 10, color, true); + } + } + canvas.lines(line, color); + } + + /** {@code drawCommonGraduationPitch1}: a scale of ticks on both sides that slides with the pitch. */ + private void pitchScale(double playerPitch, int color, int posX, int posY) { + int pitch = (int) playerPitch % 360; + int y = (int) (playerPitch * 10.0 % 10.0); + if (y < 0) { + y += 10; + } + int left = posX - 100; + int right = posX + 100; + posY -= 80; + double[] line = new double[144]; + int p = playerPitch < 0.0 && y != 0 ? pitch - 9 : pitch - 8; + for (int i = 0; i < line.length / 8; i++, p++) { + int outer = p % 3 == 0 ? 15 : 5; + double rowY = posY + i * 10 - y; + line[i * 8] = left - outer; + line[i * 8 + 1] = rowY; + line[i * 8 + 2] = left; + line[i * 8 + 3] = rowY; + line[i * 8 + 4] = right + outer; + line[i * 8 + 5] = rowY; + line[i * 8 + 6] = right; + line[i * 8 + 7] = rowY; + } + canvas.lines(line, color); + canvas.strip(new double[]{left - 25, posY - 90, left, posY - 90, left, posY + 90, left - 25, posY + 90}, color); + canvas.strip(new double[]{right + 25, posY - 90, right, posY - 90, right, posY + 90, right + 25, posY + 90}, color); + } + + /** + * {@code drawCommonGraduationPitch2}: a ladder with a rung every five degrees, dashed below + * the horizon. {@code wide} is {@code Pitch2} with five rungs; {@code Pitch3} has three, twice as far apart. + */ + private void pitchLadder(double playerPitch, int color, int posX, int posY, boolean wide) { + playerPitch = -playerPitch; + int pitchStep = (int) playerPitch / 5 * 5; + int start = wide ? 0 : 1; + int end = wide ? 5 : 4; + int spacing = wide ? 1 : 2; + double rest = playerPitch % 5.0; + double[] line = new double[8]; + for (int i = start; i < end; i++) { + int pitch = -(-pitchStep - 10 + i * 5); + int halfWidth = pitch != 0 ? 50 : 100; + int y = posY + (int) (-60 * spacing + rest * 6.0 * spacing + i * 30 * spacing); + line[0] = posX - halfWidth; + line[1] = y + (pitch == 0 ? 0 : pitch > 0 ? 2 : -2); + line[2] = posX - 50; + line[3] = y; + line[4] = posX + halfWidth; + line[5] = line[1]; + line[6] = posX + 50; + line[7] = y; + canvas.lines(line, color); + line[0] = posX - 50; + line[1] = y; + line[2] = posX - 30; + line[3] = y; + line[4] = posX + 50; + line[5] = y; + line[6] = posX + 30; + line[7] = y; + if (pitch >= 0) { + canvas.lines(line, color); + } else { + canvas.stippled(line, color, 1, DASHES); + } + if (pitch != 0) { + canvas.text(Integer.toString(pitch), posX - 60, y - 4, color, true); + canvas.text(Integer.toString(pitch), posX + 60, y - 4, color, true); + } + } + } + + /** {@code MCH_HudItemCameraRot}: a frame with a box showing where the crew's view points against the hull. */ + private void cameraRot(double posX, double posY, int color) { + canvas.loop(new double[]{posX - 21, posY - 11, posX + 21, posY - 11, posX + 21, posY + 11, posX - 21, posY + 11}, color); + canvas.stippled(new double[]{posX - 21, posY, posX, posY, posX + 21, posY, posX, posY, + posX, posY - 11, posX, posY, posX, posY + 11, posX, posY}, color, 1, DASHES); + float pitch = Mth.clamp(player.getXRot(), -30.0F, 70.0F); + pitch = (float) ((pitch - 20.0F) * 0.16); + float yaw = Mth.clamp(Mth.wrapDegrees(player.getYRot() - vehicle.getYRot()) * 2.0F, -50.0F, 50.0F); + yaw = (float) (yaw * 0.34); + canvas.loop(new double[]{posX + yaw - 3, posY + pitch - 2, posX + yaw + 3, posY + pitch - 2, + posX + yaw + 3, posY + pitch + 2, posX + yaw - 3, posY + pitch + 2}, color); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java new file mode 100644 index 0000000..5a670db --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java @@ -0,0 +1,86 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.hud.HudFormatException; +import com.jnhprojects.mcheli.hud.HudScript; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * The HUD scripts in {@code assets/mcheli/hud}, read through the resource manager so resource + * packs can replace or add them, and reloaded with F3 + T. A script that fails to parse is logged + * and left out; the vehicles using it fall back to the plain readout. + */ +public final class HudScripts implements ResourceManagerReloadListener { + public static final HudScripts INSTANCE = new HudScripts(); + private static final Logger LOGGER = LoggerFactory.getLogger(HudScripts.class); + private static final String FOLDER = "hud"; + private static final int DEFAULT_TEXTURE_SIZE = 256; + + private Map scripts = Map.of(); + private final Map textureSizes = new HashMap<>(); + + private HudScripts() { + } + + public @Nullable HudScript get(String name) { + return scripts.get(name.toLowerCase(Locale.ROOT)); + } + + @Override + public void onResourceManagerReload(ResourceManager resources) { + Map loaded = new HashMap<>(); + resources.listResources(FOLDER, id -> id.getNamespace().equals(MCHeli.MOD_ID) && id.getPath().endsWith(".txt")) + .forEach((id, resource) -> { + String path = id.getPath(); + String name = path.substring(FOLDER.length() + 1, path.length() - 4).toLowerCase(Locale.ROOT); + try (Reader in = resource.openAsReader()) { + loaded.put(name, HudScript.parse(name, in)); + } catch (IOException | HudFormatException e) { + LOGGER.error("Skipping HUD {}: {}", id, e.getMessage()); + } + }); + scripts = Map.copyOf(loaded); + textureSizes.clear(); + LOGGER.info("Loaded {} HUD scripts", scripts.size()); + } + + /** + * Width and height of a GUI texture in pixels, read from the PNG header once. The original + * fell back to 256 when it could not tell, and so does this. + */ + int[] textureSize(ResourceManager resources, ResourceLocation texture) { + return textureSizes.computeIfAbsent(texture, id -> readSize(resources.getResource(id))); + } + + private static int[] readSize(Optional resource) { + if (resource.isPresent()) { + try (InputStream in = resource.get().open(); DataInputStream data = new DataInputStream(in)) { + // 8 byte signature, IHDR length and type, then width and height as big endian ints. + data.skipNBytes(16); + int width = data.readInt(); + int height = data.readInt(); + if (width > 0 && height > 0) { + return new int[]{width, height}; + } + } catch (IOException e) { + LOGGER.warn("Could not read the size of a HUD texture", e); + } + } + return new int[]{DEFAULT_TEXTURE_SIZE, DEFAULT_TEXTURE_SIZE}; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java new file mode 100644 index 0000000..eb89586 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -0,0 +1,353 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.hud.HudVariable; +import com.jnhprojects.mcheli.hud.TextArg; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.weapon.Guidance; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.monster.Enemy; +import net.minecraft.world.level.Level; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * What the HUD scripts read, the static state of {@code MCH_HudItem}. Ticked with the client, so + * the radar sweep, altitude and stick behave as in the original; {@link #update} then fills in the + * per frame values before a script runs. + * + *

Systems that arrive later in phase 4 read as their idle state for now: fuel is full, the + * camera is in normal view at zoom 1, the pilot gunner mode is off, the vehicle inventory is empty + * and the mortar distance is unknown. + */ +final class HudState { + private static final int RADAR_RANGE = 64; + private static final int ALTITUDE_INTERVAL = 10; + private static final double STICK_MAX = 40.0; + /** Roughly the mouse units of the original per degree of pilot look. */ + private static final double STICK_PER_DEGREE = 6.0; + private static final int STICK_SAMPLES = 4; + + final HudVariable.Values vars = new HudVariable.Values(); + final List entityBlips = new ArrayList<>(); + final List enemyBlips = new ArrayList<>(); + + private @Nullable AircraftEntity vehicle; + private int radarRotate; + private int radarRotateO; + private int altitude; + private int altitudeCountdown; + private double stickX; + private double stickY; + private final double[] stickXSamples = new double[STICK_SAMPLES]; + private final double[] stickYSamples = new double[STICK_SAMPLES]; + private int stickSample; + + /** Colour set by the last {@code Color} line. Like the original it carries over between frames. */ + int color = 0xFF000000; + + // Per frame, for the text arguments. + private @Nullable WeaponSet weapon; + private String weaponName = ""; + private String weaponAmmo = ""; + private String weaponRestAmmo = ""; + private float reloadPercent; + private float reloadSeconds; + + /** Mouse look of the pilot this frame, in degrees; drives the control stick indicator. */ + void onPilotLook(float dYaw, float dPitch) { + stickX += dYaw * STICK_PER_DEGREE; + stickY += dPitch * STICK_PER_DEGREE; + double length = Math.sqrt(stickX * stickX + stickY * stickY); + if (length > STICK_MAX) { + stickX *= STICK_MAX / length; + stickY *= STICK_MAX / length; + } + } + + void tick(@Nullable AircraftEntity riding) { + if (riding != vehicle) { + vehicle = riding; + radarRotate = 0; + radarRotateO = 0; + altitudeCountdown = 0; + entityBlips.clear(); + enemyBlips.clear(); + } + stickX *= 0.85; + stickY *= 0.85; + if (riding == null || riding.definition() == null) { + return; + } + if (riding.definition().entityRadar) { + radarRotateO = radarRotate; + radarRotate += radarSpeed(riding); + if (radarRotate >= 360) { + radarRotate = 0; + radarRotateO = 0; + sweepRadar(riding); + } + } + if (--altitudeCountdown <= 0) { + altitude = measureAltitude(riding); + altitudeCountdown = ALTITUDE_INTERVAL; + } + } + + /** Degrees per tick, from each kind's client tick handler. */ + private static int radarSpeed(AircraftEntity vehicle) { + return vehicle instanceof PlaneEntity || vehicle instanceof TankEntity ? 10 : 5; + } + + /** {@code MCH_Radar.updateXZ}: mobs within 64 blocks that are not buried under five or more blocks. */ + private void sweepRadar(AircraftEntity center) { + entityBlips.clear(); + enemyBlips.clear(); + Level level = center.level(); + for (Mob mob : level.getEntitiesOfClass(Mob.class, center.getBoundingBox().inflate(RADAR_RANGE))) { + double x = mob.getX() - center.getX(); + double z = mob.getZ() - center.getZ(); + if (x * x + z * z >= RADAR_RANGE * RADAR_RANGE || isBuried(level, mob)) { + continue; + } + (mob instanceof Enemy ? enemyBlips : entityBlips).add(new double[]{x, z}); + } + } + + private static boolean isBuried(Level level, Mob mob) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(mob.getBlockX(), Math.max(mob.getBlockY() + 1, level.getMinBuildHeight()), mob.getBlockZ()); + int blocks = 0; + for (; pos.getY() < level.getMaxBuildHeight() - 1; pos.move(0, 1, 0)) { + if (!level.getBlockState(pos).isAir() && ++blocks >= 5) { + return true; + } + } + return false; + } + + /** {@code updateAltitude}: blocks of air below the vehicle, looking at most 256 down. */ + private static int measureAltitude(AircraftEntity vehicle) { + Level level = vehicle.level(); + BlockPos.MutableBlockPos pos = vehicle.blockPosition().mutable(); + int top = pos.getY(); + for (int i = 0; i < 256 && top - i > level.getMinBuildHeight(); i++) { + pos.setY(top - i); + if (!level.getBlockState(pos).isAir()) { + return i; + } + } + return 0; + } + + float radarRotation(float partialTicks) { + float rot = radarRotate; + float prev = radarRotateO; + if (rot < prev) { + rot += 360.0F; + } + return prev + (rot - prev) * partialTicks; + } + + void update(AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, int seat, double width, double height, + float partialTicks) { + HudVariable.Values v = vars; + v.set(HudVariable.COLOR, color & 0xFFFFFFFFL); + v.set(HudVariable.CENTER_X, width / 2.0); + v.set(HudVariable.CENTER_Y, height / 2.0); + v.set(HudVariable.WIDTH, width); + v.set(HudVariable.HEIGHT, height); + v.set(HudVariable.TIME, Math.floorMod(vehicle.level().getDayTime(), 24000L)); + v.set(HudVariable.TEST_MODE, 0.0); + v.set(HudVariable.PLYR_YAW, Mth.wrapDegrees(player.getViewYRot(partialTicks))); + v.set(HudVariable.PLYR_PITCH, player.getViewXRot(partialTicks)); + v.set(HudVariable.YAW, Mth.wrapDegrees(vehicle.getYRot(partialTicks))); + v.set(HudVariable.PITCH, vehicle.getXRot(partialTicks)); + v.set(HudVariable.ROLL, Mth.wrapDegrees(vehicle.roll(partialTicks))); + v.set(HudVariable.ALTITUDE, altitude); + v.set(HudVariable.SEA_ALT, Math.max(vehicle.getY() - vehicle.level().getSeaLevel(), 0.0)); + v.set(HudVariable.HAVE_RADAR, def.entityRadar); + v.set(HudVariable.RADAR_ROT, radarRotation(partialTicks)); + v.set(HudVariable.HP, vehicle.hp()); + v.set(HudVariable.MAX_HP, def.maxHp); + v.set(HudVariable.HP_RTO, def.maxHp > 0 ? (double) vehicle.hp() / def.maxHp : 0.0); + v.set(HudVariable.THROTTLE, vehicle.visibleThrottle()); + v.set(HudVariable.POS_X, vehicle.getX()); + v.set(HudVariable.POS_Y, vehicle.getY()); + v.set(HudVariable.POS_Z, vehicle.getZ()); + // Only the pilot's client simulates the hull, so read motion from the position instead. + double mx = vehicle.getX() - vehicle.xo; + double my = vehicle.getY() - vehicle.yo; + double mz = vehicle.getZ() - vehicle.zo; + v.set(HudVariable.MOTION_X, mx); + v.set(HudVariable.MOTION_Y, my); + v.set(HudVariable.MOTION_Z, mz); + v.set(HudVariable.SPEED, Math.sqrt(mx * mx + my * my + mz * mz)); + v.set(HudVariable.FUEL, 1.0); + v.set(HudVariable.LOW_FUEL, 0.0); + updateStick(seat == 0 && vehicle.steersWithMouse()); + updateWeapon(vehicle, seat); + v.set(HudVariable.VTOL_STAT, vehicle.vtolMode()); + v.set(HudVariable.FREE_LOOK, seat == 0 && vehicle.input().freeLook); + v.set(HudVariable.GUNNER_MODE, vehicle.isGunnerMode(player)); + v.set(HudVariable.CAM_MODE, 0.0); + v.set(HudVariable.CAM_ZOOM, 1.0); + v.set(HudVariable.AUTO_PILOT, vehicle instanceof PlaneEntity && vehicle.isGunnerMode(player)); + VehicleWeapons weapons = vehicle.weapons(); + boolean flares = weapons != null && weapons.flares.isFitted(); + v.set(HudVariable.HAVE_FLARE, flares); + v.set(HudVariable.CAN_FLARE, flares && !weapons.flares.isPreparing() && !weapons.flares.isActive()); + v.set(HudVariable.INVENTORY, 0.0); + v.set(HudVariable.HOVERING, vehicle instanceof HelicopterEntity && vehicle.isHoveringMode()); + v.set(HudVariable.IS_UAV, def.uav); + v.set(HudVariable.UAV_FS, 0.0); + } + + /** The original averaged the last four stick positions, scaled to -1..1, with up positive. */ + private void updateStick(boolean pilot) { + stickXSamples[stickSample] = pilot ? stickX / STICK_MAX : 0.0; + stickYSamples[stickSample] = pilot ? -stickY / STICK_MAX : 0.0; + stickSample = (stickSample + 1) % STICK_SAMPLES; + double x = 0.0; + double y = 0.0; + for (int i = 0; i < STICK_SAMPLES; i++) { + x += stickXSamples[i]; + y += stickYSamples[i]; + } + vars.set(HudVariable.STICK_X, x / STICK_SAMPLES); + vars.set(HudVariable.STICK_Y, y / STICK_SAMPLES); + } + + /** {@code updateWeapon} and {@code updateVarMap_Weapon}. */ + private void updateWeapon(AircraftEntity vehicle, int seat) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.current(seat) : null; + weapon = set; + double reloadTime = 0.0; + double heat = 0.0; + double lock = 0.0; + int sightType = 0; + if (set != null) { + WeaponDefinition def = set.definition(); + weaponName = set.displayName(); + weaponAmmo = set.magazineSize() > 0 ? "%4d".formatted(set.ammo()) : ""; + weaponRestAmmo = set.magazineSize() > 0 ? "%4d".formatted(set.restAmmo()) : ""; + float wait; + if (def.delay > def.reloadTime) { + reloadSeconds = Math.abs(set.countWait); + wait = Math.min(Math.abs((float) set.countWait / Math.max(def.delay, 1)), 1.0F); + } else { + reloadSeconds = set.countReloadWait; + wait = (float) set.countReloadWait / Math.max(def.reloadTime, 1); + } + reloadTime = wait; + reloadSeconds /= 20.0F; + reloadPercent = (1.0F - wait) * 100.0F; + if (def.maxHeatCount > 0) { + heat = Math.min((double) set.currentHeat / def.maxHeatCount, 1.0); + } + Guidance guidance = weapons.guidance(weapons.selected(seat)); + if (def.sight == WeaponDefinition.Sight.LOCK && guidance != null && guidance.lockCountMax() > 0) { + lock = (double) guidance.lockCount() / guidance.lockCountMax(); + sightType = 2; + } else if (def.sight == WeaponDefinition.Sight.ROCKET) { + sightType = 1; + } + vars.set(HudVariable.IS_HEAT_WPN, def.maxHeatCount > 0); + vars.set(HudVariable.RELOADING, set.isInPreparation()); + vars.set(HudVariable.DSP_MT_DIST, def.displayMortarDistance); + } else { + vars.set(HudVariable.IS_HEAT_WPN, 0.0); + vars.set(HudVariable.RELOADING, 0.0); + vars.set(HudVariable.DSP_MT_DIST, 0.0); + } + vars.set(HudVariable.RELOAD_TIME, reloadTime); + vars.set(HudVariable.WPN_HEAT, heat); + vars.set(HudVariable.SIGHT_TYPE, sightType); + vars.set(HudVariable.LOCK, lock); + vars.set(HudVariable.MT_DIST, -1.0); + } + + boolean hasWeapon() { + return weapon != null; + } + + boolean hasMagazine() { + return weapon != null && weapon.magazineSize() > 0; + } + + /** A {@code DrawString} argument, typed the way the original passed it to {@code String.format}. */ + @Nullable Object text(TextArg arg, AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player) { + int worldTime = (int) Math.floorMod(vehicle.level().getDayTime() + 6000L, 24000L); + return switch (arg) { + case NONE -> null; + // The vehicle inventory screen and its key come later in phase 4. + case KEY_GUI -> ""; + case NAME -> def.displayName; + case ALTITUDE -> altitude; + case DATE -> new Date(); + case MC_THOR -> worldTime / 1000; + case MC_TMIN -> worldTime % 1000 * 36 / 10 / 60; + case MC_TSEC -> worldTime % 1000 * 36 / 10 % 60; + case MAX_HP -> def.maxHp; + case HP -> vehicle.hp(); + case HP_PER -> def.maxHp > 0 ? 100.0 * vehicle.hp() / def.maxHp : 0.0; + case POS_X -> vehicle.getX(); + case POS_Y -> vehicle.getY(); + case POS_Z -> vehicle.getZ(); + case MOTION_X -> vars.get(HudVariable.MOTION_X); + case MOTION_Y -> vars.get(HudVariable.MOTION_Y); + case MOTION_Z -> vars.get(HudVariable.MOTION_Z); + case INVENTORY -> 0; + case WPN_NAME -> weaponName; + case WPN_AMMO -> weaponAmmo; + case WPN_RM_AMMO -> weaponRestAmmo; + case RELOAD_PER -> reloadPercent; + case RELOAD_SEC -> reloadSeconds; + case MORTAR_DIST -> -1.0F; + case MC_VER -> "26.3"; + case MOD_VER -> Platform.get().modVersion(); + case MOD_NAME -> "MCHeli Rework"; + case YAW -> rotate360(vehicle.getYRot() + 180.0); + case PITCH -> -vehicle.getXRot(); + case ROLL -> Mth.wrapDegrees(vehicle.roll()); + case PLYR_YAW -> rotate360(player.getYRot() + 180.0); + case PLYR_PITCH -> -player.getXRot(); + case TVM_POS_X, TVM_POS_Y, TVM_POS_Z, TVM_DIFF -> tvMissile(arg, vehicle); + case CAM_ZOOM -> 1.0F; + case UAV_DIST -> 0.0; + case THROTTLE -> vehicle.visibleThrottle() * 100.0; + }; + } + + private static double tvMissile(TextArg arg, AircraftEntity vehicle) { + TvMissileEntity missile = vehicle.tvMissile(); + if (missile == null) { + return 0.0; + } + return switch (arg) { + case TVM_POS_X -> missile.getX(); + case TVM_POS_Y -> missile.getY(); + case TVM_POS_Z -> missile.getZ(); + default -> missile.position().distanceTo(vehicle.position()); + }; + } + + static double rotate360(double degrees) { + double r = degrees % 360.0; + return r >= 0.0 ? r : r + 360.0; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 0000000..4d854b2 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.Camera; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleProvider; +import net.minecraft.client.particle.ParticleRenderType; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.particle.TextureSheetParticle; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.util.FastColor; +import net.minecraft.util.Mth; +import org.jspecify.annotations.Nullable; + +/** + * {@code mcheli:glow}: a ball of light added on top of the scene. A flash swells quickly and fades + * out; a fire holds its brightness, wavering, and only fades towards the end. How bright it gets + * depends on the light where it starts, so the same explosion is a flicker at noon and lights up + * the sky at night. + */ +public class GlowParticle extends TextureSheetParticle { + private final float size; + private final float peak; + private final boolean flicker; + private float waver = 1.0F; + + GlowParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, GlowParticleOptions options, + TextureAtlasSprite sprite, float strength) { + super(level, x, y, z); + setSprite(sprite); + setParticleSpeed(xd, yd, zd); + this.size = options.size(); + this.peak = strength; + this.flicker = options.flicker(); + this.quadSize = size; + this.lifetime = options.lifetime(); + this.friction = 0.9F; + this.gravity = 0.0F; + this.hasPhysics = false; + int color = options.color(); + setColor(FastColor.ARGB32.red(color) / 255.0F, FastColor.ARGB32.green(color) / 255.0F, FastColor.ARGB32.blue(color) / 255.0F); + this.alpha = 0.0F; + } + + @Override + public void tick() { + super.tick(); + if (flicker) { + waver = 0.7F + random.nextFloat() * 0.3F; + } + } + + @Override + public void render(VertexConsumer buffer, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + if (flicker) { + // Burning: hold, then die down over the last third. + alpha = peak * waver * Math.min(1.0F, (1.0F - t) * 3.0F); + } else { + // A flash: brightest at once, gone quickly. + float left = 1.0F - t; + alpha = peak * left * left; + } + super.render(buffer, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return flicker ? size * (0.85F + 0.15F * waver) : size * (0.6F + 0.4F * Math.min(1.0F, t * 4.0F)); + } + + @Override + protected int getLightColor(float partialTicks) { + return LightTexture.FULL_BRIGHT; + } + + @Override + public ParticleRenderType getRenderType() { + return Glow.PARTICLE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public @Nullable Particle createParticle(GlowParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd) { + float strength = Glow.strength(level, BlockPos.containing(x, y, z)); + if (strength <= 0.0F) { + return null; + } + return new GlowParticle(level, x, y, z, xd, yd, zd, options, sprites.get(level.random), strength); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java new file mode 100644 index 0000000..a212650 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; + +/** The close air support A-10, {@code MCH_RenderA10}. */ +public class A10Renderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/bullets/a10.png"); + + public A10Renderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 10.5F; + } + + @Override + public void render(A10Entity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + Mesh mesh = entity.isVisible() ? MeshCache.INSTANCE.get("", "a-10") : null; + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getYRot(partialTicks))); + pose.mulPose(Axis.XP.rotationDegrees(-entity.getXRot(partialTicks))); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(TEXTURE)), light, OverlayTexture.NO_OVERLAY); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(A10Entity entity) { + return TEXTURE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java new file mode 100644 index 0000000..626907f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -0,0 +1,67 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import org.jspecify.annotations.Nullable; + +/** + * Snapshot of a vehicle for one frame, filled in by {@link AircraftRenderer} before it draws, the + * way 26.x's render states work. Every angle is already interpolated for the frame's partial tick. + */ +public class AircraftRenderState { + /** Packed block and sky light at the vehicle. */ + public int lightCoords; + /** Definitions are immutable after loading, so the snapshot can share them. */ + public @Nullable AircraftDefinition definition; + public String texture = ""; + public float yaw; + public float pitch; + public float roll; + /** Helicopter main rotor or plane propeller angle. */ + public float rotorAngle; + public float throttle; + + // Part states, 0 to 90 degrees + public float canopy; + public float landingGear; + public float hatch; + public float nozzle; + public float wing; + + public float wheelRoll; + public float steer; + public final float[] roller = new float[2]; + /** Track run per side from 0 to 1, in link spacings. */ + public final float[] track = new float[2]; + public float[] rotatingParts = new float[0]; + + /** Per seat: occupant view relative to the hull, and whether anyone sits there. */ + public float[] seatYaw = new float[0]; + public float[] seatPitch = new float[0]; + public boolean[] seatOccupied = new boolean[0]; + /** Seat of the player at this client, or -1, and whether they look through their own eyes. */ + public int localSeat = -1; + public boolean localFirstPerson; + /** Shade for the unlit glass, from the light at the vehicle. */ + public int glassColor = 0xFFFFFFFF; + + /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ + public WeaponPartState[] weaponParts = new WeaponPartState[0]; + /** Per weapon bay, 0 to 90 degrees. */ + public float[] bays = new float[0]; + + /** How one weapon part is drawn this frame. */ + public static final class WeaponPartState { + /** Whether the part's weapon exists; without one the part follows its operator's view. */ + public boolean hasWeapon; + /** Aim relative to the weapon's default direction. */ + public float yaw; + public float pitch; + public float defaultYaw; + /** How far a turret weapon lags behind the pilot's view. */ + public float turretLag; + public float recoil; + public float barrel; + /** A missile part whose round is on its way and not reloaded yet. */ + public boolean hidden; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java new file mode 100644 index 0000000..29086b0 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -0,0 +1,227 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.AnimationState; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PartState; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.AABB; +import org.jspecify.annotations.Nullable; + +/** + * Draws any MCHeli vehicle from its MQO/OBJ model, following {@code MCH_RenderAircraft}: the hull, + * the kind's own moving parts (rotors, wings, gun mounts) and then the parts every kind can have. + */ +public class AircraftRenderer extends EntityRenderer { + /** Reused every frame; rendering is single threaded. */ + private final AircraftRenderState state = new AircraftRenderState(); + + public AircraftRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 2.0F; + } + + /** What 26.x splits off as the render state: everything the frame needs, read from the entity. */ + private void extractRenderState(T entity, AircraftRenderState state, float partialTicks, int light) { + state.lightCoords = light; + AircraftDefinition def = entity.definition(); + state.definition = def; + state.texture = entity.texture(); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.roll = entity.roll(partialTicks); + if (def == null) { + return; + } + + state.rotorAngle = switch (entity) { + case HelicopterEntity heli -> heli.rotorAngle(partialTicks); + case PlaneEntity plane -> plane.rotorAngle(partialTicks); + default -> 0.0F; + }; + state.canopy = angle(entity.canopy(), partialTicks); + state.landingGear = angle(entity.landingGear(), partialTicks); + state.hatch = angle(entity.hatch(), partialTicks); + if (entity instanceof PlaneEntity plane) { + state.nozzle = angle(plane.nozzle(), partialTicks); + state.wing = angle(plane.wing(), partialTicks); + } else { + state.nozzle = 0.0F; + state.wing = 0.0F; + } + + AnimationState anim = entity.animation; + state.throttle = Mth.lerp(partialTicks, anim.prevThrottle, anim.throttle); + state.wheelRoll = Mth.lerp(partialTicks, anim.prevWheelRoll, anim.wheelRoll); + state.steer = Mth.lerp(partialTicks, anim.prevSteer, anim.steer); + for (int i = 0; i < 2; i++) { + state.roller[i] = Mth.lerp(partialTicks, anim.prevRoller[i], anim.roller[i]); + state.track[i] = lerpWrapped(anim.prevTrack[i], anim.track[i], partialTicks); + } + if (state.rotatingParts.length != anim.rotatingParts.length) { + state.rotatingParts = new float[anim.rotatingParts.length]; + } + for (int i = 0; i < anim.rotatingParts.length; i++) { + state.rotatingParts[i] = Mth.lerp(partialTicks, anim.prevRotatingParts[i], anim.rotatingParts[i]); + } + + int seats = def.seatAndRackCount(); + if (state.seatYaw.length != seats) { + state.seatYaw = new float[seats]; + state.seatPitch = new float[seats]; + state.seatOccupied = new boolean[seats]; + } + for (int i = 0; i < seats; i++) { + state.seatYaw[i] = anim.seatYaw(i, partialTicks); + state.seatPitch[i] = anim.seatPitch(i, partialTicks); + state.seatOccupied[i] = entity.occupant(i) != null; + } + Minecraft mc = Minecraft.getInstance(); + state.localSeat = mc.player != null ? entity.seatOf(mc.player) : -1; + state.localFirstPerson = mc.options.getCameraType().isFirstPerson(); + state.glassColor = glassColor(entity, state.lightCoords); + extractWeapons(entity, def, state, partialTicks); + } + + /** + * Glass is drawn without the light map, so shade it by hand: as bright as the brighter of block + * light and the sky light left after night or rain darkens it. + */ + private static int glassColor(AircraftEntity entity, int lightCoords) { + int sky = Math.max(LightTexture.sky(lightCoords) - entity.level().getSkyDarken(), 0); + int light = Math.max(LightTexture.block(lightCoords), sky); + int shade = (int) ((0.3F + 0.7F * light / 15.0F) * 255.0F); + return 0xFF000000 | shade << 16 | shade << 8 | shade; + } + + /** {@code renderWeapon}'s inputs: each part's weapon aim, recoil, barrel spin and reload state. */ + private static void extractWeapons(AircraftEntity entity, AircraftDefinition def, AircraftRenderState state, float partialTicks) { + VehicleWeapons weapons = entity.weapons(); + int parts = def.weaponParts.size(); + if (state.weaponParts.length != parts) { + state.weaponParts = new AircraftRenderState.WeaponPartState[parts]; + for (int i = 0; i < parts; i++) { + state.weaponParts[i] = new AircraftRenderState.WeaponPartState(); + } + } + WeaponSet previous = null; + int mount = 0; + for (int i = 0; i < parts; i++) { + WeaponPart part = def.weaponParts.get(i); + AircraftRenderState.WeaponPartState out = state.weaponParts[i]; + WeaponSet set = weapons != null && !part.weapons().isEmpty() ? weapons.byName(part.weapons().getFirst()) : null; + if (set != previous) { + mount = 0; + previous = set; + } + out.hasWeapon = set != null; + if (set == null) { + continue; + } + float yaw = set.rotationYaw; + float prevYaw = set.prevRotationYaw; + if (yaw - prevYaw > 180.0F) { + prevYaw += 360.0F; + } else if (yaw - prevYaw < -180.0F) { + prevYaw -= 360.0F; + } + out.yaw = Mth.lerp(partialTicks, prevYaw, yaw) - set.defaultRotationYaw; + out.pitch = Mth.lerp(partialTicks, set.prevRotationPitch, set.rotationPitch); + out.defaultYaw = set.defaultRotationYaw; + out.turretLag = set.rotationTurretYaw; + out.barrel = Mth.lerp(partialTicks, set.prevBarrel, set.barrel); + // A part shared by several weapons kicks back with whichever fired hardest. + float recoil = set.recoil(0, partialTicks); + for (String name : part.weapons()) { + WeaponSet other = weapons.byName(name); + if (other != null) { + recoil = Math.max(recoil, other.recoil(0, partialTicks)); + } + } + out.recoil = recoil; + out.hidden = part.missile() && weapons.isMountUsed(weapons.indexOf(set.name()), mount); + mount++; + } + if (state.bays.length != def.weaponBays.size()) { + state.bays = new float[def.weaponBays.size()]; + } + for (int i = 0; i < state.bays.length; i++) { + state.bays[i] = weapons != null ? weapons.bayAngle(i, partialTicks) : 0.0F; + } + } + + private static float angle(@Nullable PartState part, float partialTicks) { + return part != null ? part.angle(partialTicks) : 0.0F; + } + + /** Interpolates a value that wraps from 1 back to 0. */ + private static float lerpWrapped(float prev, float current, float partialTicks) { + float d = current - prev; + if (d > 0.5F) { + d -= 1.0F; + } else if (d < -0.5F) { + d += 1.0F; + } + float v = prev + d * partialTicks; + return v - Mth.floor(v); + } + + @Override + public boolean shouldRender(T entity, Frustum frustum, double camX, double camY, double camZ) { + return entity.shouldRender(camX, camY, camZ) && frustum.isVisible(cullingBox(entity)); + } + + /** The hit box is far smaller than most models, so cull on a generous box instead. */ + private static AABB cullingBox(AircraftEntity entity) { + AircraftDefinition def = entity.definition(); + double reach = def != null ? Math.max(8.0, def.markerWidth * 4.0) : 8.0; + return entity.getBoundingBox().inflate(reach, reach / 2.0, reach); + } + + @Override + public void render(T entity, float entityYaw, float partialTicks, PoseStack poseStack, MultiBufferSource buffers, int light) { + extractRenderState(entity, state, partialTicks, light); + AircraftDefinition def = state.definition; + Mesh mesh = def != null ? MeshCache.INSTANCE.get(def.folder(), def.name()) : null; + if (mesh != null) { + // Canopies and windshields are semi-transparent texels; PartRenderer draws those + // triangles blended, as the original did, and everything else as cutout. + poseStack.pushPose(); + poseStack.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + poseStack.mulPose(Axis.XP.rotationDegrees(state.pitch)); + poseStack.mulPose(Axis.ZP.rotationDegrees(state.roll)); + new PartRenderer(def, mesh, state, poseStack, buffers, texture(def.folder(), state.texture), state.lightCoords, + OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); + poseStack.popPose(); + } + super.render(entity, entityYaw, partialTicks, poseStack, buffers, light); + } + + /** Vehicles bind their own textures per part; this is only for vanilla's bookkeeping. */ + @Override + public ResourceLocation getTextureLocation(T entity) { + return texture(entity.definition() != null ? entity.definition().folder() : "", entity.texture()); + } + + private static ResourceLocation texture(String folder, String name) { + return RuntimeAssets.texture(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 0000000..4d577f0 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.entity.projectile.FlareEntity; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; + +/** + * Flares and the burning debris of a crash were only their particle trails. Not in the original: + * a burning flare now glows, which is what makes a flare salvo light up the night sky. Smoke + * grenades stay invisible. + */ +public class FlareRenderer extends EntityRenderer { + private static final float RADIUS = 1.4F; + + public FlareRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + @Override + public void render(FlareEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + float glow = entity.isSmoke() || entity.isInWater() ? 0.0F : Glow.strength(entity.level(), entity.blockPosition()); + // A cheap waver keyed to the entity, so neighbouring flares do not pulse in step. + float flicker = 0.8F + 0.2F * (float) Math.sin((entity.tickCount + partialTicks) * 1.7F + entity.getId()); + Quaternionf camera = entityRenderDispatcher.cameraOrientation(); + Glow.halo(pose, buffers, camera, Vec3.ZERO, RADIUS * flicker, GlowParticleOptions.FIRE, glow); + Glow.halo(pose, buffers, camera, Vec3.ZERO, RADIUS * 0.35F, GlowParticleOptions.FLASH, glow); + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(FlareEntity entity) { + return Glow.TEXTURE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java new file mode 100644 index 0000000..f80e3ae --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.resources.ResourceLocation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Sorts a model's triangles into opaque ones and see-through glass, by the alpha of the texture + * under each triangle. + * + *

Drawing a whole vehicle translucent made its glass write depth before the game drew water, + * so water behind a canopy vanished. Opaque triangles now draw as cutout, like any entity, and + * only the glass draws translucent, through a type that leaves the depth buffer alone. + */ +final class GlassSplit { + static final GlassSplit INSTANCE = new GlassSplit(); + private static final Logger LOGGER = LoggerFactory.getLogger(GlassSplit.class); + /** Texels between these alphas are glass; fully clear ones are cut out, solid ones opaque. */ + private static final int MIN_GLASS_ALPHA = 10; + private static final int MAX_GLASS_ALPHA = 245; + + record Split(List opaque, List glass) { + static final Split EMPTY = new Split(List.of(), List.of()); + } + + private record Alpha(int width, int height, byte[] values) { + int at(float u, float v) { + int x = Math.floorMod((int) Math.floor(u * width), width); + int y = Math.floorMod((int) Math.floor(v * height), height); + return values[y * width + x] & 0xFF; + } + } + + private final Map> textures = new HashMap<>(); + private final Map, Map> splits = new IdentityHashMap<>(); + + private GlassSplit() { + } + + /** Called from the render thread only. */ + Split split(List groups, ResourceLocation texture) { + if (groups.isEmpty()) { + return Split.EMPTY; + } + return splits.computeIfAbsent(groups, g -> new HashMap<>()).computeIfAbsent(texture, t -> compute(groups, t)); + } + + void clear() { + textures.clear(); + splits.clear(); + } + + private Split compute(List groups, ResourceLocation texture) { + Alpha alpha = textures.computeIfAbsent(texture, GlassSplit::load).orElse(null); + if (alpha == null) { + return new Split(groups, List.of()); + } + List opaque = new ArrayList<>(); + List glass = new ArrayList<>(); + for (MeshGroup group : groups) { + split(group, alpha, opaque, glass); + } + return new Split(List.copyOf(opaque), List.copyOf(glass)); + } + + private static void split(MeshGroup group, Alpha alpha, List opaque, List glass) { + float[] uv = group.uvs(); + int triangles = group.vertexCount() / 3; + boolean[] isGlass = new boolean[triangles]; + int glassCount = 0; + for (int t = 0; t < triangles; t++) { + int i = t * 6; + float u = (uv[i] + uv[i + 2] + uv[i + 4]) / 3.0F; + float v = (uv[i + 1] + uv[i + 3] + uv[i + 5]) / 3.0F; + int a = alpha.at(u, v); + if (a > MIN_GLASS_ALPHA && a < MAX_GLASS_ALPHA) { + isGlass[t] = true; + glassCount++; + } + } + if (glassCount == 0) { + opaque.add(group); + } else if (glassCount == triangles) { + glass.add(group); + } else { + opaque.add(pick(group, isGlass, false, triangles - glassCount)); + glass.add(pick(group, isGlass, true, glassCount)); + } + } + + private static MeshGroup pick(MeshGroup group, boolean[] isGlass, boolean wanted, int count) { + float[] p = new float[count * 9]; + float[] n = new float[count * 9]; + float[] uv = new float[count * 6]; + int out = 0; + for (int t = 0; t < isGlass.length; t++) { + if (isGlass[t] != wanted) { + continue; + } + System.arraycopy(group.positions(), t * 9, p, out * 9, 9); + System.arraycopy(group.normals(), t * 9, n, out * 9, 9); + System.arraycopy(group.uvs(), t * 6, uv, out * 6, 6); + out++; + } + return new MeshGroup(group.name(), p, n, uv); + } + + private static Optional load(ResourceLocation texture) { + try (InputStream in = RuntimeAssets.open(texture)) { + if (in == null) { + return Optional.empty(); + } + return Optional.of(read(in)); + } catch (IOException | RuntimeException e) { + LOGGER.warn("Could not read {} to find its glass; drawing it opaque", texture, e); + return Optional.empty(); + } + } + + private static Alpha read(InputStream in) throws IOException { + try (NativeImage image = NativeImage.read(in)) { + int w = image.getWidth(); + int h = image.getHeight(); + byte[] values = new byte[w * h]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + values[y * w + x] = (byte) (image.getPixelRGBA(x, y) >>> 24); + } + } + return new Alpha(w, h, values); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 0000000..305050d --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,188 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.effect.NightGlow; +import com.mojang.blaze3d.platform.GlStateManager; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.Tesselator; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.minecraft.client.particle.ParticleRenderType; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderStateShard; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.renderer.texture.TextureManager; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FastColor; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; + +/** + * Not in the original: light that adds to the scene instead of covering it, for tracers, rocket + * motors, flares and fireballs. Everything here blends additively and leaves the depth buffer + * alone, so overlapping glows brighten each other and never cut holes in the smoke behind them. + */ +public final class Glow { + /** A white disc fading to nothing at the rim; also the {@code mcheli:glow} particle sprite. */ + public static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/particle/glow.png"); + + /** The particle render type for {@code mcheli:glow}: additive, depth tested, no depth writes. */ + public static final ParticleRenderType PARTICLE_LAYER = new ParticleRenderType() { + @Override + public BufferBuilder begin(Tesselator tesselator, TextureManager textures) { + RenderSystem.depthMask(false); + RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES); + RenderSystem.enableBlend(); + RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE); + return tesselator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE); + } + + @Override + public String toString() { + return "MCHELI_GLOW"; + } + }; + + private static final RenderType HALO = Types.halo(); + + private static final int FULL_BRIGHT = LightTexture.FULL_BRIGHT; + + private Glow() { + } + + /** + * How strongly something at {@code pos} glows: faint by day, full at night, in caves and + * under water. 0 when the player turned the glow off. + */ + public static float strength(Level level, BlockPos pos) { + if (!ClientConfig.nightGlow()) { + return 0.0F; + } + return NightGlow.strength(level.getBrightness(LightLayer.BLOCK, pos), level.getBrightness(LightLayer.SKY, pos), level.getSkyDarken()); + } + + /** + * A disc of light facing the camera, centred {@code offset} away from the current pose. + * + * @param camera the camera's orientation, from the entity render dispatcher + * @param color RGB; {@code strength} becomes the alpha, which scales the light added + */ + public static void halo(PoseStack pose, MultiBufferSource buffers, Quaternionf camera, Vec3 offset, float radius, int color, + float strength) { + if (strength <= 0.0F || radius <= 0.0F) { + return; + } + int argb = FastColor.ARGB32.color(alpha(strength), color); + pose.pushPose(); + pose.translate(offset.x, offset.y, offset.z); + pose.mulPose(camera); + PoseStack.Pose p = pose.last(); + VertexConsumer buffer = buffers.getBuffer(HALO); + // Both windings, so culling never hides it whichever way the quad ends up facing. + vertex(buffer, p, -radius, -radius, 0.0F, 0.0F, 1.0F, argb); + vertex(buffer, p, radius, -radius, 0.0F, 1.0F, 1.0F, argb); + vertex(buffer, p, radius, radius, 0.0F, 1.0F, 0.0F, argb); + vertex(buffer, p, -radius, radius, 0.0F, 0.0F, 0.0F, argb); + vertex(buffer, p, -radius, radius, 0.0F, 0.0F, 0.0F, argb); + vertex(buffer, p, radius, radius, 0.0F, 1.0F, 0.0F, argb); + vertex(buffer, p, radius, -radius, 0.0F, 1.0F, 1.0F, argb); + vertex(buffer, p, -radius, -radius, 0.0F, 0.0F, 1.0F, argb); + pose.popPose(); + } + + /** + * A tracer: a ribbon of light from the current pose back along {@code tail}, turned to face + * the camera, bright at the head and fading out towards the end. + * + * @param toCamera from the camera to the head, so the ribbon can be turned side on to the view + */ + public static void streak(PoseStack pose, MultiBufferSource buffers, Vec3 toCamera, Vec3 tail, float width, int color, float strength) { + if (strength <= 0.0F || tail.lengthSqr() < 1.0E-4) { + return; + } + Vec3 side = tail.cross(toCamera); + if (side.lengthSqr() < 1.0E-8) { + // Seen exactly end on; the halo at the head covers this. + return; + } + side = side.normalize().scale(width); + int head = FastColor.ARGB32.color(alpha(strength), color); + int end = FastColor.ARGB32.color(0, color); + float sx = (float) side.x; + float sy = (float) side.y; + float sz = (float) side.z; + float tx = (float) tail.x; + float ty = (float) tail.y; + float tz = (float) tail.z; + PoseStack.Pose p = pose.last(); + VertexConsumer buffer = buffers.getBuffer(HALO); + // The middle row of the disc texture gives the ribbon soft edges. + vertex(buffer, p, sx, sy, sz, 0.0F, 0.5F, head); + vertex(buffer, p, -sx, -sy, -sz, 1.0F, 0.5F, head); + vertex(buffer, p, tx - sx, ty - sy, tz - sz, 1.0F, 0.5F, end); + vertex(buffer, p, tx + sx, ty + sy, tz + sz, 0.0F, 0.5F, end); + vertex(buffer, p, tx + sx, ty + sy, tz + sz, 0.0F, 0.5F, end); + vertex(buffer, p, tx - sx, ty - sy, tz - sz, 1.0F, 0.5F, end); + vertex(buffer, p, -sx, -sy, -sz, 1.0F, 0.5F, head); + vertex(buffer, p, sx, sy, sz, 0.0F, 0.5F, head); + } + + private static int alpha(float strength) { + return Math.clamp(Math.round(strength * 255.0F), 0, 255); + } + + private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float x, float y, float z, float u, float v, int argb) { + buffer.addVertex(pose, x, y, z) + .setColor(argb) + .setUv(u, v) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(FULL_BRIGHT) + .setNormal(pose, 0.0F, 1.0F, 0.0F); + } + + /** + * Builds the halo render type. A subclass only to reach the protected render state shards; + * {@code RenderType.create} is not public on every loader, the constructor is. + */ + private abstract static class Types extends RenderType { + private Types() { + super("", DefaultVertexFormat.NEW_ENTITY, VertexFormat.Mode.QUADS, 0, false, false, () -> { + }, () -> { + }); + } + + /** The eyes shader (full bright, no light map), additive, depth tested without depth writes. */ + static RenderType halo() { + RenderStateShard[] shards = { + RENDERTYPE_EYES_SHADER, + new TextureStateShard(TEXTURE, false, false), + LIGHTNING_TRANSPARENCY, + COLOR_WRITE, + LEQUAL_DEPTH_TEST + }; + return new RenderType(MCHeli.MOD_ID + "_glow", DefaultVertexFormat.NEW_ENTITY, VertexFormat.Mode.QUADS, + TRANSIENT_BUFFER_SIZE, false, true, + () -> { + for (RenderStateShard shard : shards) { + shard.setupRenderState(); + } + }, + () -> { + for (RenderStateShard shard : shards) { + shard.clearRenderState(); + } + }) { + }; + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java new file mode 100644 index 0000000..1fc66d2 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java @@ -0,0 +1,82 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshLoader; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Lazily loads vehicle models, from the resource packs (so they can replace them) or else from the + * runtime roots of {@link RuntimeAssets}. Models are parsed the first time a vehicle of that kind + * is drawn, and dropped on resource reload and on content reload ({@link #clear}). + */ +public final class MeshCache implements ResourceManagerReloadListener { + public static final MeshCache INSTANCE = new MeshCache(); + private static final Logger LOGGER = LoggerFactory.getLogger(MeshCache.class); + private static final String[] EXTENSIONS = {".mqo", ".obj"}; + + private final Map> meshes = new ConcurrentHashMap<>(); + + private MeshCache() { + } + + /** + * @param folder content folder, for example {@code helicopters}, or empty for the models root + * @param name definition name + * @return the mesh, or {@code null} if no model exists or it failed to load + */ + public @Nullable Mesh get(String folder, String name) { + return meshes.computeIfAbsent(folder + '/' + name, key -> load(folder, name, true)).orElse(null); + } + + /** Like {@link #get}, for optional models such as separate part files; a missing one is not logged. */ + public @Nullable Mesh find(String folder, String name) { + return meshes.computeIfAbsent(folder + '/' + name, key -> load(folder, name, false)).orElse(null); + } + + private static Optional load(String folder, String name, boolean required) { + for (String extension : EXTENSIONS) { + ResourceLocation id = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, + (folder.isEmpty() ? "models/" : "models/" + folder + "/") + name + extension); + try (InputStream in = RuntimeAssets.open(id)) { + if (in == null) { + continue; + } + Mesh mesh = MeshLoader.load(id.toString(), in); + LOGGER.debug("Loaded {}", mesh); + return Optional.of(mesh); + } catch (IOException | RuntimeException e) { + LOGGER.error("Failed to load model {}", id, e); + return Optional.empty(); + } + } + if (required) { + LOGGER.warn("No model found for {}/{}", folder, name); + } + return Optional.empty(); + } + + /** Forgets every model and glass split, so the next frame reads them again from disk. */ + public void clear() { + meshes.clear(); + GlassSplit.INSTANCE.clear(); + } + + @Override + public void onResourceManagerReload(ResourceManager resourceManager) { + clear(); + RuntimeAssets.clearTextures(); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java new file mode 100644 index 0000000..c367447 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -0,0 +1,517 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.part.CameraPart; +import com.jnhprojects.mcheli.content.aircraft.part.CrawlerTrack; +import com.jnhprojects.mcheli.content.aircraft.part.LandingGearPart; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.content.aircraft.part.PropellerPart; +import com.jnhprojects.mcheli.content.aircraft.part.RotatingPart; +import com.jnhprojects.mcheli.content.aircraft.part.ThrottlePart; +import com.jnhprojects.mcheli.content.aircraft.part.TrackRoller; +import com.jnhprojects.mcheli.content.aircraft.part.VehiclePart; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponBayPart; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; +import com.jnhprojects.mcheli.content.aircraft.part.WheelPart; +import com.jnhprojects.mcheli.content.aircraft.part.WingPart; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import org.joml.Vector3f; + +import java.util.List; + +/** + * Submits a vehicle's hull and moving parts for one frame. A port of the part routines in + * {@code MCH_RenderAircraft} and the per kind renderers; every transform is applied in hull space + * on top of the pose the caller already set up. + * + *

A part is looked up as {@code $name} inside the vehicle model, or as a separate + * {@code _} model file, the two places the original accepted. + */ +final class PartRenderer { + private static final Vector3 X_AXIS = new Vector3(1, 0, 0); + private static final Vector3 DOWN_AXIS = new Vector3(0, -1, 0); + + private final AircraftDefinition def; + private final Mesh mesh; + private final AircraftRenderState state; + private final PoseStack pose; + private final MultiBufferSource buffers; + private final ResourceLocation texture; + private final RenderType opaqueType; + private final RenderType glassType; + private final int light; + private final int overlay; + /** Glass draws unlit, so its colour carries the brightness of where the vehicle stands. */ + private final int glassColor; + + PartRenderer(AircraftDefinition def, Mesh mesh, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers, + ResourceLocation texture, int light, int overlay, int glassColor) { + this.def = def; + this.mesh = mesh; + this.state = state; + this.pose = pose; + this.buffers = buffers; + this.texture = texture; + // Culled, as the original drew it. The models are full of back to back faces for thin + // panels, and drawing both sides of those makes them fight for the same depth. + this.opaqueType = RenderType.entityCutout(texture); + // The one entity translucent type that does not write depth, so water behind glass still + // draws. It does not cull, so MeshRenderer drops the back faces itself. + this.glassType = RenderType.entityTranslucentEmissive(texture); + this.light = light; + this.overlay = overlay; + this.glassColor = glassColor; + } + + void submitAll() { + switch (def) { + case PlaneDefinition plane -> { + nozzles(plane); + wings(plane); + propellers(plane); + submit(mesh.body()); + } + case HelicopterDefinition heli -> { + submit(mesh.body()); + rotors(heli); + } + case VehicleDefinition vehicle -> { + submit(mesh.body()); + for (VehiclePart part : vehicle.parts) { + vehiclePart(vehicle, part); + } + } + default -> submit(mesh.body()); + } + weapons(); + rotatingParts(); + movingParts(def.hatches, state.hatch); + trackRollers(); + crawlerTracks(); + steeringWheels(); + wheels(); + throttles(); + cameras(); + landingGear(); + weaponBays(); + movingParts(def.canopies, state.canopy); + } + + // Kind specific parts + + private void rotors(HelicopterDefinition heli) { + for (RotorDefinition rotor : heli.rotors) { + List blade = part(rotor.part()); + if (blade.isEmpty() || isZero(rotor.axis())) { + continue; + } + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + int blades = Math.max(rotor.blades(), 1); + for (int i = 0; i < blades; i++) { + pose.pushPose(); + Vector3 p = rotor.position(); + translate(p, 1.0F); + rotate(state.rotorAngle + i * step, rotor.axis()); + if (!rotor.legacyPivot()) { + translate(p, -1.0F); + } + submit(blade); + pose.popPose(); + } + } + } + + private void nozzles(PlaneDefinition plane) { + for (MovingPart nozzle : plane.nozzles) { + pose.pushPose(); + rotateAround(nozzle.position(), state.nozzle, nozzle.axis()); + submit(part(nozzle.part())); + pose.popPose(); + } + } + + private void wings(PlaneDefinition plane) { + for (WingPart wing : plane.wings) { + pose.pushPose(); + rotateAround(wing.position(), state.wing * wing.factor(), wing.axis()); + submit(part(wing.part())); + for (MovingPart pylon : wing.pylons()) { + pose.pushPose(); + rotateAround(pylon.position(), state.wing * pylon.factor(), pylon.axis()); + submit(part(pylon.part())); + pose.popPose(); + } + pose.popPose(); + } + } + + /** Tilt rotors follow the nozzle state, the blades spin with the engine. */ + private void propellers(PlaneDefinition plane) { + if (!plane.hasNozzleState()) { + return; + } + for (PropellerPart rotor : plane.propellers) { + pose.pushPose(); + rotateAround(rotor.position(), state.nozzle * rotor.factor(), rotor.axis()); + submit(part(rotor.part())); + for (PropellerPart.Blade blade : rotor.blades()) { + List groups = part(blade.part()); + pose.pushPose(); + rotateAround(blade.position(), state.rotorAngle, blade.axis()); + for (int i = 0; i < blade.count(); i++) { + rotateAround(blade.position(), blade.step(), blade.axis()); + submit(groups); + } + pose.popPose(); + } + pose.popPose(); + } + } + + /** {@code MCH_RenderVehicle.drawPart}: mount parts turn with the gunner's view. */ + private void vehiclePart(VehicleDefinition vehicle, VehiclePart part) { + pose.pushPose(); + if (part.yaw() || part.pitch() || part.type() == VehiclePart.TYPE_ROTATING_BARREL) { + translate(part.position(), 1.0F); + if (part.yaw()) { + rotate(seatYaw(0), DOWN_AXIS); + } + if (part.pitch()) { + rotate(Mth.clamp(seatPitch(0), vehicle.minRotationPitch, vehicle.maxRotationPitch), X_AXIS); + } + translate(part.position(), -1.0F); + } + for (VehiclePart child : part.children()) { + vehiclePart(vehicle, child); + } + boolean gunnerLooking = state.localSeat == 0 && state.localFirstPerson; + if (part.drawFirstPerson() || !gunnerLooking) { + submit(part(part.part())); + } + pose.popPose(); + } + + // Parts every kind can have + + /** + * Gun mounts, turrets and missiles, {@code renderWeapon}. Each follows its weapon's aim, kicks + * back as it fires, spins its barrels and, for missiles, disappears until reloaded. A part + * whose weapon is missing follows the view of the seat that would fire it. + */ + private void weapons() { + float turretYaw = seatYaw(0); + for (int i = 0; i < def.weaponParts.size(); i++) { + WeaponPart w = def.weaponParts.get(i); + AircraftRenderState.WeaponPartState ws = i < state.weaponParts.length ? state.weaponParts[i] : null; + boolean live = ws != null && ws.hasWeapon; + WeaponMount mount = mount(w); + int seat = mount != null ? mount.seatIndex() : 0; + if (w.hideInFirstPerson() && state.localFirstPerson && state.localSeat == seat) { + continue; + } + float defaultYaw = live ? ws.defaultYaw : mount != null ? mount.defaultYaw() : 0.0F; + float yaw; + float pitch; + if (live) { + yaw = ws.yaw; + pitch = ws.pitch; + } else { + yaw = Mth.wrapDegrees(seatYaw(seat) - defaultYaw); + pitch = seatPitch(seat); + } + + pose.pushPose(); + if (w.turret()) { + rotateAround(def.turretPosition, turretYaw, DOWN_AXIS); + } + translate(w.position(), 1.0F); + if (w.yaw()) { + rotate(w.turret() && !live ? yaw - turretYaw : yaw, DOWN_AXIS); + } + if (w.turret() && live) { + rotate(-(turretYaw - ws.turretLag), DOWN_AXIS); + } + // Side facing guns pitch around their own axis, which the original did by turning + // into the gun's frame and flipping the sign for guns pointing left or right. + boolean sideways = false; + if ((int) defaultYaw != 0) { + float t = Mth.wrapDegrees(defaultYaw); + sideways = (t >= 45.0F && t <= 135.0F) || (t <= -45.0F && t >= -135.0F); + rotate(-defaultYaw, DOWN_AXIS); + } + if (w.pitch()) { + rotate(sideways ? -pitch : pitch, X_AXIS); + } + if (live && w.recoil() != 0.0F) { + pose.translate(0.0F, 0.0F, w.recoil() * ws.recoil); + } + if (mount != null || live) { + rotate(defaultYaw, DOWN_AXIS); + } + if (live && w.rotatingBarrel()) { + rotate(ws.barrel, w.axis()); + } + translate(w.position(), -1.0F); + if (!w.missile() || !live || !ws.hidden) { + submit(part(w.part())); + for (WeaponPart.Child child : w.children()) { + pose.pushPose(); + translate(child.position(), 1.0F); + if (child.yaw()) { + rotate(yaw, DOWN_AXIS); + } + if (live && (int) defaultYaw != 0) { + rotate(-defaultYaw, DOWN_AXIS); + } + if (child.pitch()) { + rotate(sideways ? -pitch : pitch, X_AXIS); + } + if (live && child.recoil() != 0.0F) { + pose.translate(0.0F, 0.0F, -child.recoil() * ws.recoil); + } + if (live && (int) defaultYaw != 0) { + rotate(defaultYaw, DOWN_AXIS); + } + translate(child.position(), -1.0F); + submit(part(child.part())); + pose.popPose(); + } + } + pose.popPose(); + } + } + + private WeaponMount mount(WeaponPart part) { + if (part.weapons().isEmpty()) { + return null; + } + int group = def.weaponGroup(part.weapons().getFirst()); + return group >= 0 ? def.weaponGroups.get(group).mounts().getFirst() : null; + } + + private void rotatingParts() { + for (int i = 0; i < def.rotatingParts.size() && i < state.rotatingParts.length; i++) { + RotatingPart part = def.rotatingParts.get(i); + pose.pushPose(); + rotateAround(part.position(), state.rotatingParts[i], part.axis()); + submit(part(part.part())); + pose.popPose(); + } + } + + /** Hatches and canopies: swing by {@code angle * factor}, or slide along their offset. */ + private void movingParts(List parts, float angle) { + for (MovingPart part : parts) { + pose.pushPose(); + moveOrSwing(part, angle); + submit(part(part.part())); + pose.popPose(); + } + } + + private void moveOrSwing(MovingPart part, float angle) { + if (part.slide()) { + translate(part.position(), angle / 90.0F); + } else { + rotateAround(part.position(), angle * part.factor(), part.axis()); + } + } + + private void trackRollers() { + for (TrackRoller roller : def.trackRollers) { + pose.pushPose(); + rotateAround(roller.position(), state.roller[roller.side()], X_AXIS); + submit(part(roller.part())); + pose.popPose(); + } + } + + /** + * Each link slides towards the next one by the track's run, so the loop appears to move. + * The link part is modelled at the origin and placed at each point of the outline. + */ + private void crawlerTracks() { + for (CrawlerTrack track : def.crawlerTracks) { + List link = part(track.part()); + List links = track.links(); + int count = links.size() - 1; + if (link.isEmpty() || count < 1) { + continue; + } + float run = state.track[track.side()]; + for (int i = 0; i < count; i++) { + CrawlerTrack.Link cur = links.get(i); + CrawlerTrack.Link next = links.get((i + 1) % count); + float angleNext = next.angle(); + if (angleNext - cur.angle() < -180.0F) { + angleNext += 360.0F; + } else if (angleNext - cur.angle() > 180.0F) { + angleNext -= 360.0F; + } + pose.pushPose(); + pose.translate(0.0F, cur.y() + (next.y() - cur.y()) * run, cur.z() + (next.z() - cur.z()) * run); + pose.mulPose(Axis.XN.rotationDegrees(cur.angle() + (angleNext - cur.angle()) * run)); + submit(link); + pose.popPose(); + } + } + } + + private void steeringWheels() { + for (WheelPart wheel : def.steeringWheels) { + pose.pushPose(); + rotateAround(wheel.position(), state.steer * wheel.steerAngle(), wheel.axis()); + submit(part(wheel.part())); + pose.popPose(); + } + } + + private void wheels() { + for (WheelPart wheel : def.wheels) { + pose.pushPose(); + rotateAround(wheel.pivot(), state.steer * wheel.steerAngle(), wheel.axis()); + rotateAround(wheel.position(), state.wheelRoll, X_AXIS); + submit(part(wheel.part())); + pose.popPose(); + } + } + + private void throttles() { + for (ThrottlePart lever : def.throttleParts) { + pose.pushPose(); + rotateAround(lever.position(), state.throttle * lever.angle(), lever.axis()); + translate(lever.slide(), state.throttle); + submit(part(lever.part())); + pose.popPose(); + } + } + + /** Sensor turrets look where the gunner looks, or the pilot when the gunner seat is empty. */ + private void cameras() { + int seat = state.seatOccupied.length > 1 && state.seatOccupied[1] ? 1 : 0; + for (CameraPart camera : def.cameraParts) { + pose.pushPose(); + translate(camera.position(), 1.0F); + if (camera.yawSync()) { + rotate(seatYaw(seat), DOWN_AXIS); + } + if (camera.pitchSync()) { + rotate(seatPitch(seat), X_AXIS); + } + translate(camera.position(), -1.0F); + submit(part(camera.part())); + pose.popPose(); + } + } + + /** + * Gear legs swing up with the gear state; reversed parts close as the gear rises, and gear bay + * doors open fully halfway through and shut again. + */ + private void landingGear() { + float angle = state.landingGear; + float reverse = 90.0F - angle; + float door = Math.min(90.0F, 90.0F * Mth.sin(angle * 2.0F * Mth.DEG_TO_RAD) * 3.0F); + for (LandingGearPart gear : def.landingGear) { + pose.pushPose(); + translate(gear.position(), 1.0F); + if (gear.reverse()) { + rotate(reverse * gear.factor(), gear.axis()); + } else { + rotate((gear.hatch() ? door : angle) * gear.factor(), gear.axis()); + } + if (gear.secondAxis() != null) { + rotate((gear.reverse() ? reverse : angle) * gear.secondFactor(), gear.secondAxis()); + } + translate(gear.position(), -1.0F); + if (gear.slide() != null) { + float f = angle / 90.0F; + translate(gear.slide(), gear.reverse() ? 1.0F - f : f); + } + submit(part(gear.part())); + pose.popPose(); + } + } + + /** Bays swing open while a crew member has one of their weapons selected. */ + private void weaponBays() { + for (int i = 0; i < def.weaponBays.size(); i++) { + WeaponBayPart bay = def.weaponBays.get(i); + pose.pushPose(); + moveOrSwing(bay.door(), i < state.bays.length ? state.bays[i] : 0.0F); + submit(part(bay.door().part())); + pose.popPose(); + } + } + + // Helpers + + private float seatYaw(int seat) { + return seat >= 0 && seat < state.seatYaw.length ? state.seatYaw[seat] : 0.0F; + } + + private float seatPitch(int seat) { + return seat >= 0 && seat < state.seatPitch.length ? state.seatPitch[seat] : 0.0F; + } + + private List part(String name) { + List groups = mesh.part("$" + name); + if (!groups.isEmpty()) { + return groups; + } + Mesh separate = MeshCache.INSTANCE.find(def.folder(), def.name() + "_" + name); + return separate != null ? separate.groups() : List.of(); + } + + private void submit(List groups) { + if (groups.isEmpty()) { + return; + } + GlassSplit.Split split = GlassSplit.INSTANCE.split(groups, texture); + if (!split.opaque().isEmpty()) { + MeshRenderer.emit(split.opaque(), pose.last(), buffers.getBuffer(opaqueType), light, overlay); + } + if (!split.glass().isEmpty()) { + MeshRenderer.emitFrontFaces(split.glass(), pose.last(), buffers.getBuffer(glassType), light, overlay, glassColor); + } + } + + private void translate(Vector3 v, float scale) { + pose.translate((float) v.x() * scale, (float) v.y() * scale, (float) v.z() * scale); + } + + private void rotateAround(Vector3 pivot, float degrees, Vector3 axis) { + if (degrees == 0.0F || isZero(axis)) { + return; + } + translate(pivot, 1.0F); + rotate(degrees, axis); + translate(pivot, -1.0F); + } + + /** Like {@code glRotatef}: any axis length, a zero axis does nothing. */ + private void rotate(float degrees, Vector3 axis) { + if (degrees == 0.0F || isZero(axis)) { + return; + } + Vector3f a = new Vector3f((float) axis.x(), (float) axis.y(), (float) axis.z()).normalize(); + pose.mulPose(Axis.of(a).rotationDegrees(degrees)); + } + + private static boolean isZero(Vector3 v) { + return v.x() * v.x() + v.y() * v.y() + v.z() * v.z() < 1.0E-12; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java new file mode 100644 index 0000000..5a34b6e --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -0,0 +1,178 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.weapon.WeaponColor; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.BombEntity; +import com.jnhprojects.mcheli.entity.projectile.BulletEntity; +import com.jnhprojects.mcheli.entity.projectile.MarkerRocketEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.entity.projectile.RocketEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Mth; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; + +/** + * Draws rounds, rockets, missiles and bombs from {@code models/bullets}, tinted with the weapon's + * {@code BulletColor} (its in-water colour below the surface) and always fully lit, as the + * original's {@code MCH_RenderBulletBase} did. + * + *

Not in the original: rounds trail a tracer and rocket motors burn with a glow, faint by day + * and bright at night, so a firefight in the dark can be followed by eye. Nothing glows under + * water. + */ +public class ProjectileRenderer extends EntityRenderer { + private static final int FULL_BRIGHT = 0xF000F0; + /** Warm tracer colour for rounds whose weapon keeps the default white tint. */ + private static final int TRACER = 0xFFC46A; + /** The longest tracer ribbon, in blocks; fast rounds cover far more than this per tick. */ + private static final double MAX_TRACER = 6.0; + /** How far behind the projectile's centre the motor glow sits, in blocks. */ + private static final double MOTOR_OFFSET = 0.6; + + public ProjectileRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + enum GlowKind { + NONE, + TRACER, + MOTOR + } + + /** What 26.x splits off as the render state. */ + static class State { + double x; + double y; + double z; + String model = ""; + float yaw; + float pitch; + int color = 0xFFFFFFFF; + boolean visible = true; + GlowKind glowKind = GlowKind.NONE; + float glow; + int glowColor = TRACER; + /** Distance covered over the last tick, which points the tracer and the motor glow. */ + Vec3 travel = Vec3.ZERO; + } + + private final State state = new State(); + + private static void extractRenderState(ProjectileEntity entity, State state, float partialTicks) { + state.x = Mth.lerp(partialTicks, entity.xo, entity.getX()); + state.y = Mth.lerp(partialTicks, entity.yo, entity.getY()); + state.z = Mth.lerp(partialTicks, entity.zo, entity.getZ()); + state.model = entity.model(); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = !(entity instanceof BombEntity bomb) || bomb.isVisible(); + WeaponDefinition w = entity.weapon(); + boolean inWater = inWater(entity.level(), entity); + WeaponColor c = w != null ? (inWater ? w.colorInWater : w.color) : WeaponColor.WHITE; + state.color = c.argb(); + + state.glowKind = inWater ? GlowKind.NONE : glowKind(entity); + if (state.glowKind != GlowKind.NONE) { + state.glow = Glow.strength(entity.level(), entity.blockPosition()); + state.travel = new Vec3(entity.getX() - entity.xo, entity.getY() - entity.yo, entity.getZ() - entity.zo); + if (state.glowKind == GlowKind.MOTOR) { + state.glowColor = GlowParticleOptions.FIRE; + } else { + // A weapon with its own tint keeps it for the tracer; the default white burns warm. + int tint = state.color & 0xFFFFFF; + state.glowColor = tint == 0xFFFFFF ? TRACER : tint; + } + } + } + + private static GlowKind glowKind(ProjectileEntity entity) { + if (entity instanceof BulletEntity) { + return GlowKind.TRACER; + } + if (entity instanceof RocketEntity || entity instanceof MarkerRocketEntity || entity instanceof AAMissileEntity + || entity instanceof ASMissileEntity || entity instanceof ATMissileEntity || entity instanceof TvMissileEntity) { + return GlowKind.MOTOR; + } + return GlowKind.NONE; + } + + /** The original looked for water at the round and up to two blocks above it. */ + private static boolean inWater(Level level, ProjectileEntity e) { + for (int y = 0; y < 3; y++) { + if (level.getFluidState(BlockPos.containing(e.getX() + 0.5, e.getY() + 1.5 - y, e.getZ() + 0.5)).is(FluidTags.WATER)) { + return true; + } + } + return false; + } + + @Override + public void render(ProjectileEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + extractRenderState(entity, state, partialTicks); + Mesh mesh = state.visible ? MeshCache.INSTANCE.find("bullets", state.model) : null; + if (mesh != null) { + ResourceLocation texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/bullets/" + state.model + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.pitch)); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityTranslucentCull(texture)), FULL_BRIGHT, + OverlayTexture.NO_OVERLAY, state.color); + pose.popPose(); + } + if (state.visible) { + submitGlow(state, pose, buffers); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ProjectileEntity entity) { + return ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/bullets/" + entity.model() + ".png"); + } + + private Quaternionf camera() { + return entityRenderDispatcher.cameraOrientation(); + } + + private void submitGlow(State state, PoseStack pose, MultiBufferSource buffers) { + if (state.glowKind == GlowKind.NONE || state.glow <= 0.0F) { + return; + } + double speed = state.travel.length(); + Vec3 back = speed > 1.0E-3 ? state.travel.scale(-1.0 / speed) : Vec3.ZERO; + switch (state.glowKind) { + case TRACER -> { + Vec3 cameraPos = entityRenderDispatcher.camera.getPosition(); + Vec3 toCamera = new Vec3(state.x - cameraPos.x, state.y - cameraPos.y, state.z - cameraPos.z); + Glow.streak(pose, buffers, toCamera, back.scale(Math.min(speed, MAX_TRACER)), 0.08F, state.glowColor, state.glow); + Glow.halo(pose, buffers, camera(), Vec3.ZERO, 0.35F, state.glowColor, state.glow); + } + case MOTOR -> { + Vec3 nozzle = back.scale(MOTOR_OFFSET); + Glow.halo(pose, buffers, camera(), nozzle, 1.1F, state.glowColor, state.glow); + Glow.halo(pose, buffers, camera(), nozzle, 0.4F, GlowParticleOptions.FLASH, state.glow); + } + default -> { + } + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java new file mode 100644 index 0000000..7b2b587 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -0,0 +1,40 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; + +/** + * Tilts players riding a vehicle with its hull, as the original did from its render hook. + * + *

Vanilla keeps riders upright, so a banked plane left the pilot standing straight up through + * the canopy. The tilt is the hull's pitch and roll expressed in the world frame, applied about + * the rider's vehicle attachment point (the hip), which is the point that sits on the seat. The + * rider's own body yaw is applied by the renderer afterwards, so turning in the seat still works. + * + *

The entity render dispatcher pushes a pose around every entity, so nothing needs popping here. + */ +public final class RiderPose { + private RiderPose() { + } + + /** Called before a living entity is drawn, with the pose the renderer is about to use. */ + public static void beforeRender(LivingEntity rider, PoseStack pose, float partialTicks) { + if (!(rider instanceof Player) || !(rider.getVehicle() instanceof AircraftEntity vehicle)) { + return; + } + float yaw = vehicle.getYRot(partialTicks); + float pitch = vehicle.getXRot(partialTicks); + float roll = vehicle.roll(partialTicks); + if (pitch == 0.0F && roll == 0.0F) { + return; + } + Quaternionf tilt = AircraftRotation.orientation(yaw, pitch, roll).rotateY((float) Math.toRadians(yaw)); + Vec3 hip = rider.getVehicleAttachmentPoint(vehicle); + pose.rotateAround(tilt, (float) hip.x, (float) hip.y, (float) hip.z); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java similarity index 93% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java index 75c7d5f..b0d46e3 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -7,7 +7,6 @@ import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.entity.Entity; -import net.minecraftforge.event.TickEvent; import java.util.HashMap; import java.util.Map; @@ -26,11 +25,7 @@ public final class EngineSoundPlayer { private EngineSoundPlayer() { } - public static void init() { - TickEvent.ClientTickEvent.Post.BUS.addListener(event -> tick()); - } - - private static void tick() { + public static void tick() { Minecraft mc = Minecraft.getInstance(); ClientLevel level = mc.level; LocalPlayer player = mc.player; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 0000000..398c8a8 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -0,0 +1,82 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import net.minecraft.Util; +import net.minecraft.client.resources.sounds.AbstractTickableSoundInstance; +import net.minecraft.client.resources.sounds.Sound; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.JOrbisAudioStream; +import net.minecraft.client.sounds.LoopingAudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.client.sounds.WeighedSoundEvents; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.util.valueproviders.ConstantFloat; +import org.jspecify.annotations.Nullable; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * A sound that plays through the sound manager when a resource pack defines its event, and + * otherwise streams the OGG file a runtime root holds for it ({@link RuntimeAssets#soundFile}). + * Content added while the game runs can bring sounds this way without touching + * {@code sounds.json} or reloading resources. + */ +public abstract class RuntimeSoundInstance extends AbstractTickableSoundInstance { + private @Nullable Path file; + + protected RuntimeSoundInstance(SoundEvent event, SoundSource source, RandomSource random) { + super(event, source, random); + } + + @Override + public @Nullable WeighedSoundEvents resolve(SoundManager manager) { + WeighedSoundEvents events = manager.getSoundEvent(location); + if (events != null) { + sound = events.getSound(random); + return events; + } + file = RuntimeAssets.soundFile(location.getPath()); + if (file == null) { + // What vanilla does for an unknown event: play nothing. + sound = SoundManager.EMPTY_SOUND; + return null; + } + // Streamed, so the file is read as it plays and never cached as a buffer. + sound = new Sound(location, ConstantFloat.of(1.0F), ConstantFloat.of(1.0F), 1, Sound.Type.FILE, true, false, 16); + return new WeighedSoundEvents(location, null); + } + + /** + * Forge and NeoForge call this through their sound instance extension, which it overrides + * without saying so: Fabric has no such method and calls {@link #openStream} from a mixin. + */ + public CompletableFuture getStream(SoundBufferLibrary buffers, Sound sound, boolean looping) { + return openStream(buffers, sound.getPath(), looping); + } + + /** Streams the file found on disk, or the resource {@code path} when there is none. */ + public CompletableFuture openStream(SoundBufferLibrary buffers, ResourceLocation path, boolean looping) { + Path source = file; + if (source == null) { + return buffers.getStream(path, looping); + } + return CompletableFuture.supplyAsync(() -> { + try { + InputStream in = new BufferedInputStream(Files.newInputStream(source)); + return looping ? new LoopingAudioStream(JOrbisAudioStream::new, in) : new JOrbisAudioStream(in); + } catch (IOException e) { + throw new CompletionException(e); + } + }, Util.nonCriticalIoPool()); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java new file mode 100644 index 0000000..bd9c1c8 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java @@ -0,0 +1,2019 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.BoundingBoxDefinition; +import com.jnhprojects.mcheli.content.aircraft.RackDefinition; +import com.jnhprojects.mcheli.content.aircraft.RideRack; +import com.jnhprojects.mcheli.content.aircraft.SeatDefinition; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import com.jnhprojects.mcheli.physics.GroundImpact; +import com.jnhprojects.mcheli.weapon.Explosions; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.tags.DamageTypeTags; +import net.minecraft.tags.FluidTags; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Behaviour shared by every MCHeli vehicle: which definition it uses, hit points, seats, moving + * parts and the pilot authority model. + * + *

Movement is simulated wherever {@link #isControlledByLocalInstance()} is true. With a player + * at the controls that is the pilot's client, exactly like vanilla boats, and the server checks + * the result through the regular vehicle move packet. Without a pilot the server simulates the + * vehicle on its own. Roll, throttle and key state are not part of the vanilla packet, so the + * pilot sends them separately and the server republishes them through synched data. + * + *

Seats are explicit. Vanilla only knows an ordered passenger list, so the seat each passenger + * occupies is kept in a synched seat map of entity ids. Seat zero is the pilot and decides who + * controls the vehicle; crew seats follow, then cargo racks. + */ +public abstract class AircraftEntity extends Entity { + // VehicleEntity's hurt wobble. Vanilla 1.21.1 keeps that class package private where it matters, + // so vehicles extend Entity and carry their own. + private static final EntityDataAccessor DATA_HURT_TIME = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_HURT_DIR = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TYPE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.STRING); + private static final EntityDataAccessor DATA_TEXTURE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.STRING); + private static final EntityDataAccessor DATA_HP = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_THROTTLE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.FLOAT); + private static final EntityDataAccessor DATA_REVERSE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.FLOAT); + private static final EntityDataAccessor DATA_ROLL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.FLOAT); + private static final EntityDataAccessor DATA_HOVERING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_GUNNER_MODE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_INPUT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_PARTS = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_SEATS = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.STRING); + private static final EntityDataAccessor DATA_WEAPON_SELECTION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.STRING); + private static final EntityDataAccessor DATA_WEAPON_USE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TV_MISSILE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_FLARE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SINKING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + // Bits of DATA_PARTS, the same assignment the original used for its part status word. + protected static final int PART_CANOPY = 0; + protected static final int PART_NOZZLE = 1; + protected static final int PART_LANDING_GEAR = 2; + protected static final int PART_WING = 3; + protected static final int PART_HATCH = 4; + + private static final int EMPTY = -1; + /** How far above its hit box the original drew a vehicle's model; see {@link #waterDepth}. */ + private static final float LEGACY_MODEL_LIFT = 0.35F; + /** How fast a cooked-off boat goes under, in blocks per tick (about 0.4 blocks a second). */ + private static final double SINK_SPEED = 0.02; + /** The bow-up pitch and the list a sinking boat settles into, in degrees. */ + private static final float SINK_PITCH = 14.0F; + private static final float SINK_LIST = 12.0F; + + protected final ControlInput input = new ControlInput(); + public final AnimationState animation = new AnimationState(); + + /** Throttle in [0, 1] as seen by the simulating side. Other sides read {@link #DATA_THROTTLE}. */ + protected double throttle; + /** Reverse throttle in [0, 0.6] for vehicles with {@code EnableBack}. */ + protected float reverse; + protected float roll; + public float rollO; + protected int modeSwitchCooldown; + private int ticksAlive; + + private @Nullable AircraftDefinition cachedDefinition; + private String cachedName = ""; + private int cachedGeneration; + + protected @Nullable PartState canopy; + protected @Nullable PartState landingGear; + protected @Nullable PartState hatch; + private final List parts = new ArrayList<>(); + + private int[] seatIds = new int[0]; + private int pendingSeat = EMPTY; + private final Map savedSeats = new HashMap<>(); + private final Map lastSeats = new HashMap<>(); + + private @Nullable VehicleWeapons weapons; + private int @Nullable [] savedAmmo; + private @Nullable TvMissileEntity tvMissile; + + // Damage model + /** Damage factor of the extra hit box the last projectile ray struck, {@code lastBBDamageFactor}. */ + private float hitBoxFactor = 1.0F; + private int timeSinceHit; + /** How long a wreck stays, burning, before it disappears: 30 seconds. */ + private static final int WRECK_TICKS = 600; + /** Ticks a server position update is eased in over. */ + private static final int LERP_STEPS = 3; + /** The wreck fire dies down over the last five seconds. */ + private static final int WRECK_FADE_TICKS = 100; + /** Ticks until a destroyed wreck disappears; above zero means destroyed. Server only. */ + private int despawnCount; + private int lerpSteps; + private double lerpX; + private double lerpY; + private double lerpZ; + private double lerpYRot; + private double lerpXRot; + /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ + private int wreckTicks; + private float destroyedYaw; + private float destroyedPitch; + private float destroyedRoll; + private boolean exploded; + /** Blasts still to come while a wrecked boat cooks off, and ticks until the next one. */ + private int cookOffBlasts; + private int cookOffDelay; + private @Nullable Entity cookOffCause; + private int alertCount; + + // Crashes, measured from position so they work whichever side simulates the vehicle. + /** Blocks per tick into terrain, sideways, that count as a crash: about 25 m/s. */ + private static final double TERRAIN_CRASH_SPEED = 1.25; + /** Closing speed between two vehicles that counts as a crash: about 10 m/s. */ + private static final double VEHICLE_CRASH_SPEED = 0.5; + /** Highest point since a ground vehicle left the ground, or NaN while it is on it. */ + private double airborneFromY = Double.NaN; + private @Nullable Vec3 lastTickPos; + private Vec3 lastVelocity = Vec3.ZERO; + private int collisionCooldown; + + protected AircraftEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_HURT_TIME, 0); + builder.define(DATA_HURT_DIR, 1); + builder.define(DATA_TYPE, ""); + builder.define(DATA_TEXTURE, ""); + builder.define(DATA_HP, 0); + builder.define(DATA_THROTTLE, 0.0F); + builder.define(DATA_REVERSE, 0.0F); + builder.define(DATA_ROLL, 0.0F); + builder.define(DATA_HOVERING, false); + builder.define(DATA_GUNNER_MODE, false); + builder.define(DATA_INPUT, (byte) 0); + builder.define(DATA_PARTS, 0); + builder.define(DATA_SEATS, ""); + builder.define(DATA_WEAPON_SELECTION, ""); + builder.define(DATA_WEAPON_USE, 0); + builder.define(DATA_TV_MISSILE, 0); + builder.define(DATA_FLARE, false); + builder.define(DATA_SINKING, false); + } + + // Definition + + /** + * Looks the definition up by name. Returns {@code null} for vehicles whose content was removed. + * After a content reload the next call picks up the new definition object. + */ + public @Nullable AircraftDefinition definition() { + String name = typeName(); + int generation = MCHeli.content().generation(); + if (!name.equals(cachedName) || generation != cachedGeneration) { + boolean reloaded = name.equals(cachedName); + cachedName = name; + cachedGeneration = generation; + AircraftDefinition previous = cachedDefinition; + cachedDefinition = name.isEmpty() ? null : lookup(name); + if (cachedDefinition != null && cachedDefinition != previous) { + if (reloaded && weapons != null) { + // The same vehicle with new numbers: keep the rounds it carries. + savedAmmo = weapons.saveAmmo(); + } + onDefinitionChanged(cachedDefinition); + } + } + return cachedDefinition; + } + + /** Finds a definition of this entity's kind. */ + protected abstract @Nullable AircraftDefinition lookup(String name); + + /** Builds the per vehicle state that depends on the definition: seats, parts, animation arrays. */ + protected void onDefinitionChanged(AircraftDefinition def) { + if (seatIds.length != def.seatAndRackCount()) { + int[] resized = new int[def.seatAndRackCount()]; + Arrays.fill(resized, EMPTY); + System.arraycopy(seatIds, 0, resized, 0, Math.min(seatIds.length, resized.length)); + seatIds = resized; + } + parts.clear(); + canopy = def.canopies.isEmpty() ? null : addPart(PART_CANOPY, 3.5F, PartState.Sounds.CANOPY); + landingGear = def.landingGear.isEmpty() ? null : addPart(PART_LANDING_GEAR, 2.5F, PartState.Sounds.LANDING_GEAR); + hatch = def.hatches.isEmpty() ? null : addPart(PART_HATCH, 1.5F, PartState.Sounds.HATCH); + animation.resize(def); + weapons = new VehicleWeapons(this, def); + if (savedAmmo != null) { + weapons.loadAmmo(savedAmmo); + savedAmmo = null; + } + } + + protected PartState addPart(int bit, float speed, PartState.Sounds sounds) { + PartState state = new PartState(this, bit, speed, sounds); + // A vehicle that comes into view with a part already switched should not animate it. + state.force(state.status()); + parts.add(state); + return state; + } + + /** Sets which vehicle this is. Call once, right after construction, before adding to the level. */ + public void setType(AircraftDefinition definition) { + entityData.set(DATA_TYPE, definition.name()); + entityData.set(DATA_TEXTURE, definition.textures().getFirst()); + entityData.set(DATA_HP, definition.maxHp); + refreshDimensions(); + definition(); + } + + public String typeName() { + return entityData.get(DATA_TYPE); + } + + public String texture() { + String texture = entityData.get(DATA_TEXTURE); + return texture.isEmpty() ? typeName() : texture; + } + + public int hp() { + return entityData.get(DATA_HP); + } + + /** Whether the flight model holds a hover: in hover mode, or while the pilot mans the guns. */ + public boolean isHovering() { + return isHoveringMode() || isGunnerMode(); + } + + /** The hover mode the helicopter pilot switches with jump. */ + public boolean isHoveringMode() { + return entityData.get(DATA_HOVERING); + } + + protected void setHovering(boolean hovering) { + entityData.set(DATA_HOVERING, hovering); + } + + // Pilot gunner mode + + /** + * Pilot gunner mode ({@code EnableGunnerMode}): the pilot leaves the controls to man the + * weapons from the camera position. Helicopters hold a hover; planes level off and circle. + */ + public boolean isGunnerMode() { + return entityData.get(DATA_GUNNER_MODE); + } + + /** {@code canSwitchGunnerMode}: the kinds add their own attitude and speed limits. */ + public boolean canSwitchGunnerMode() { + AircraftDefinition def = definition(); + if (def == null || !def.gunnerMode || !isCanopyClosed() || isDestroyed() || isHoveringMode()) { + return false; + } + // Without ConcurrentGunnerMode a player in the gunner seat has the guns already. + return def.concurrentGunnerMode || !(occupant(1) instanceof Player); + } + + /** Server side. Leaving gunner mode is always allowed; entering it only when {@link #canSwitchGunnerMode}. */ + public void setGunnerMode(boolean on) { + if (!on || canSwitchGunnerMode()) { + entityData.set(DATA_GUNNER_MODE, on); + } + } + + /** Whether {@code entity} is the pilot and currently in gunner mode. */ + public boolean isGunnerMode(Entity entity) { + return isGunnerMode() && seatOf(entity) == 0; + } + + /** Drops gunner mode once its conditions no longer hold. Server side, every tick. */ + private void checkGunnerMode(AircraftDefinition def) { + if (isGunnerMode() && (occupant(0) == null || isDestroyed() + || (!def.concurrentGunnerMode && occupant(1) instanceof Player))) { + entityData.set(DATA_GUNNER_MODE, false); + } + } + + // Throttle, roll and input + + /** Throttle for animation and sound, valid on every side. */ + public float visibleThrottle() { + return isSimulatingHere() ? (float) throttle : entityData.get(DATA_THROTTLE); + } + + public float visibleReverse() { + return isSimulatingHere() ? reverse : entityData.get(DATA_REVERSE); + } + + public float roll() { + return isSimulatingHere() ? roll : entityData.get(DATA_ROLL); + } + + public float roll(float partialTicks) { + return Mth.rotLerp(partialTicks, rollO, roll()); + } + + public void setRoll(float roll) { + this.roll = roll; + } + + protected boolean isSimulatingHere() { + return isControlledByLocalInstance() && !isPassenger(); + } + + public ControlInput input() { + return input; + } + + /** + * Applies the pilot's view change for one frame. Only called on the pilot's client, and only + * for kinds that steer with the mouse. + * + * @param dYaw change of the player's yaw since the last frame, in degrees + * @param dPitch change of the player's pitch since the last frame, in degrees + */ + public void applyPilotLook(float dYaw, float dPitch) { + } + + /** Whether the pilot steers with the mouse, as in helicopters and planes. */ + public boolean steersWithMouse() { + return false; + } + + /** Server side entry point for the pilot's control packet. */ + public void acceptPilotState(float throttle, float reverse, float roll, boolean hovering, byte keys) { + this.throttle = Mth.clamp(throttle, 0.0F, 1.0F); + this.reverse = Mth.clamp(reverse, 0.0F, 1.0F); + this.roll = AircraftRotation.wrap(roll); + input.read(keys); + entityData.set(DATA_THROTTLE, (float) this.throttle); + entityData.set(DATA_REVERSE, this.reverse); + entityData.set(DATA_ROLL, this.roll); + entityData.set(DATA_HOVERING, hovering); + entityData.set(DATA_INPUT, keys); + } + + public float reverse() { + return reverse; + } + + // Tick + + @Override + public void tick() { + rollO = roll(); + if (getHurtTime() > 0) { + setHurtTime(getHurtTime() - 1); + } + super.tick(); + if (level().isClientSide()) { + tickLerp(); + } + ticksAlive++; + lastSeats.clear(); + if (modeSwitchCooldown > 0) { + modeSwitchCooldown--; + } + + AircraftDefinition def = definition(); + if (def == null) { + // Content for this vehicle no longer exists. Keep the entity inert rather than crash. + setDeltaMovement(Vec3.ZERO); + return; + } + if (timeSinceHit > 0) { + timeSinceHit--; + } + if (alertCount > 0) { + alertCount--; + } + boolean groundedBefore = onGround(); + double fallBefore = getDeltaMovement().y; + tickDestroyed(); + + if (isSimulatingHere()) { + if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { + input.clear(); + } + if (entityData.get(DATA_SINKING)) { + sink(); + } else { + simulate(); + } + float[] kick = weapons != null ? weapons.recoilKick() : null; + if (kick != null) { + setXRot(getXRot() + kick[0]); + roll += kick[1]; + } + if (!level().isClientSide()) { + entityData.set(DATA_THROTTLE, (float) throttle); + entityData.set(DATA_REVERSE, reverse); + entityData.set(DATA_ROLL, roll); + entityData.set(DATA_INPUT, input.write()); + } + } else { + if (!isControlledByLocalInstance()) { + input.read(entityData.get(DATA_INPUT)); + } + setDeltaMovement(Vec3.ZERO); + } + for (PartState part : parts) { + part.tick(); + } + if (!level().isClientSide()) { + updatePartsServer(def); + checkGunnerMode(def); + } + animation.tick(this, def); + tickAnimation(); + if (weapons != null) { + weapons.tick(); + if (!level().isClientSide()) { + entityData.set(DATA_FLARE, weapons.flares.isActive()); + warnIncomingMissiles(); + } + } + if (level().isClientSide()) { + damageSmoke(def); + } else { + tickImpacts(def, groundedBefore, fallBefore); + } + } + + /** + * Server side crashes. A wreck explodes when it lands; an intact vehicle takes damage when it + * meets the ground steeply tilted ({@code onUpdate_CollisionGroundDamage}), sits under water + * without floats, flies into terrain, or rams another vehicle. + */ + private void tickImpacts(AircraftDefinition def, boolean groundedBefore, double fallBefore) { + Vec3 velocity = lastTickPos != null ? position().subtract(lastTickPos) : Vec3.ZERO; + Vec3 previous = lastVelocity; + lastTickPos = position(); + lastVelocity = velocity; + if (collisionCooldown > 0) { + collisionCooldown--; + } + double speed = Math.max(velocity.length(), previous.length()); + + if (isDestroyed()) { + boolean falling = fallBefore < -0.2 || previous.y < -0.2; + boolean landed = onGround() && !groundedBefore; + boolean smashed = horizontalCollision && previous.horizontalDistance() > 0.5; + if (!exploded && (landed && falling || smashed)) { + crashExplosion(); + } + return; + } + + if (isNearGround(3)) { + float roll = Math.abs(Mth.wrapDegrees(roll())); + float pitch = Math.abs(Mth.wrapDegrees(getXRot())); + if (roll > crashTilt() || pitch > crashTilt()) { + float damage = roll + pitch; + damage *= damage < 90.0F ? 0.4F * (float) velocity.length() : 0.4F; + if (damage > 1.0F && random.nextInt(4) == 0) { + hurt(damageSources().inWall(), damage); + } + } + } + if (ticksAlive % 30 == 0 && !def.floats && level().getFluidState( + BlockPos.containing(getX() + 0.5, getY() + 1.5 + def.submergedDamageHeight, getZ() + 0.5)).is(FluidTags.WATER)) { + hurt(damageSources().inWall(), Math.max(def.maxHp / 10, 1)); + } + + if (takesGroundImpacts()) { + groundImpacts(def, previous); + } + + // Not in the original: flying into terrain or another vehicle at speed is a crash. + if (horizontalCollision && previous.horizontalDistance() > TERRAIN_CRASH_SPEED && collisionCooldown == 0) { + collisionCooldown = 20; + hurt(damageSources().flyIntoWall(), crashDamage(previous.horizontalDistance())); + } + if (speed > VEHICLE_CRASH_SPEED && collisionCooldown == 0) { + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(0.5), + o -> o != this && !o.isRemoved() && o.getVehicle() != this && getVehicle() != o)) { + double closing = velocity.subtract(other.lastVelocity).length(); + closing = Math.max(closing, previous.subtract(other.lastVelocity).length()); + if (closing > VEHICLE_CRASH_SPEED && other.collisionCooldown == 0) { + collisionCooldown = 20; + other.collisionCooldown = 20; + float damage = crashDamage(closing); + hurt(damageSources().flyIntoWall(), damage); + other.hurt(damageSources().flyIntoWall(), damage); + break; + } + } + } + } + + /** {@code getGiveDamageRot}: how far a vehicle may lean near the ground before it scrapes. */ + protected float crashTilt() { + return 40.0F; + } + + /** Crash damage grows with the square of the impact speed, like the energy involved. */ + private static float crashDamage(double speed) { + return (float) (speed * speed * 60.0); + } + + /** Whether hitting walls and falling hurt this vehicle. Tanks and cars, which live on the ground. */ + protected boolean takesGroundImpacts() { + return false; + } + + /** + * A ground vehicle that hits a wall or lands after a long drop takes a share of its hit points + * by the speed or the height, see {@link GroundImpact}. The same height costs a car and a tank + * the same share before armour. + */ + private void groundImpacts(AircraftDefinition def, Vec3 previous) { + if (horizontalCollision && previous.horizontalDistance() > GroundImpact.WALL_SPEED && collisionCooldown == 0) { + collisionCooldown = 20; + hurt(damageSources().flyIntoWall(), GroundImpact.wall(previous.horizontalDistance()) * def.maxHp); + } + if (isInWater() || isPassenger()) { + airborneFromY = Double.NaN; + } else if (!onGround()) { + airborneFromY = Double.isNaN(airborneFromY) ? getY() : Math.max(airborneFromY, getY()); + } else if (!Double.isNaN(airborneFromY)) { + double drop = airborneFromY - getY(); + airborneFromY = Double.NaN; + if (drop > GroundImpact.SAFE_DROP) { + hurt(damageSources().fall(), GroundImpact.drop(drop) * def.maxHp); + } + } + } + + /** {@code MCH_MissileDetector}: the crew hears a warning while a missile homes on them. */ + private void warnIncomingMissiles() { + if (alertCount > 0 || isDestroyed() || weapons == null || !weapons.flares.isFitted() || ticksAlive % 5 != 0) { + return; + } + boolean tracked = !level().getEntitiesOfClass(ProjectileEntity.class, getBoundingBox().inflate(60.0), + p -> p.target() != null && (p.target() == this || hasPassenger(p.target()))).isEmpty(); + if (tracked) { + alertCount = 20; + ModSounds.playAt(this, "alert", 1.0F, 1.0F); + } + } + + /** One tick of control and physics on the simulating side. */ + protected abstract void simulate(); + + /** Visual state that every side updates, for example rotor angles. */ + protected void tickAnimation() { + } + + /** Ticks since this entity instance was created, used to delay controls right after spawning. */ + protected int ticksAlive() { + return ticksAlive; + } + + // Moving parts + + int partBits() { + return entityData.get(DATA_PARTS); + } + + void setPartBit(int bit, boolean on) { + int bits = entityData.get(DATA_PARTS); + entityData.set(DATA_PARTS, on ? bits | (1 << bit) : bits & ~(1 << bit)); + } + + public @Nullable PartState canopy() { + return canopy; + } + + public @Nullable PartState landingGear() { + return landingGear; + } + + public @Nullable PartState hatch() { + return hatch; + } + + public boolean isCanopyClosed() { + return canopy == null || canopy.isOff(); + } + + protected void openCanopy() { + if (canopy != null && modeSwitchCooldown <= 0) { + canopy.set(true, true); + modeSwitchCooldown = 20; + } + } + + protected void closeCanopy() { + if (canopy != null && modeSwitchCooldown <= 0 && canopy.status()) { + canopy.set(false, true); + modeSwitchCooldown = 20; + } + } + + public boolean isLandingGearUp() { + return landingGear != null && landingGear.status(); + } + + /** Gear can be raised once the vehicle is at least ten blocks above anything solid. */ + public boolean canRaiseLandingGear() { + return landingGear != null && landingGear.angle() < 1.0F && !landingGear.status() && !hasBlockBelow(3, 10, true); + } + + public boolean canLowerLandingGear() { + return landingGear != null && landingGear.angle() >= 89.0F && landingGear.status(); + } + + public void toggleLandingGear() { + if (landingGear == null || modeSwitchCooldown > 0) { + return; + } + if (canRaiseLandingGear()) { + landingGear.set(true, true); + modeSwitchCooldown = 20; + } else if (canLowerLandingGear()) { + landingGear.set(false, true); + modeSwitchCooldown = 20; + } + } + + public boolean canFoldHatch() { + return hatch != null && modeSwitchCooldown <= 0 && hatch.isOff(); + } + + public boolean canUnfoldHatch() { + return hatch != null && modeSwitchCooldown <= 0 && hatch.isOn(); + } + + /** The fold key: hatches by default, planes fold or sweep their wings instead. */ + public void toggleFold() { + if (canFoldHatch()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } else if (canUnfoldHatch()) { + hatch.set(false, true); + modeSwitchCooldown = 20; + } + } + + /** Throttle below which the landing gear comes down by itself near the ground. */ + protected float autoLowerGearThrottle() { + return 0.8F; + } + + /** {@code updateParts}: automatic landing gear, plus whatever a kind adds. Server only. */ + protected void updatePartsServer(AircraftDefinition def) { + if (landingGear == null) { + return; + } + if (!landingGear.status() && landingGear.factor() <= 0.1F) { + // Amphibians retract the gear when they settle on water. + boolean onWater = isInWater() || isWaterBelow(20); + if (def.floats && onWater && (throttle <= 0.8 || onGround() || hasBlockBelow(3, 20, true))) { + landingGear.set(true, true); + } + } else if (landingGear.status() && landingGear.factor() >= 0.9F) { + boolean ground = hasBlockBelow(3, 10, true); + if (throttle < autoLowerGearThrottle() && ground) { + if (!def.floats || !isWaterBelow(150)) { + landingGear.set(false, true); + } + } else if (vtolMode() == 2 && ground) { + landingGear.set(false, true); + } + } + } + + /** 0 off, 1 tilting, 2 fully in VTOL mode. Only planes have one. */ + public int vtolMode() { + return 0; + } + + // Seats + + /** Seat (or rack) index of {@code entity}, or -1. */ + public int seatOf(Entity entity) { + int id = entity.getId(); + for (int i = 0; i < seatIds.length; i++) { + if (seatIds[i] == id) { + return i; + } + } + return EMPTY; + } + + public @Nullable Entity occupant(int seat) { + if (seat < 0 || seat >= seatIds.length || seatIds[seat] == EMPTY) { + return null; + } + int id = seatIds[seat]; + for (Entity passenger : getPassengers()) { + if (passenger.getId() == id) { + return passenger; + } + } + return null; + } + + public @Nullable SeatDefinition seat(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && seat >= 0 && seat < def.seatCount() ? def.seats.get(seat) : null; + } + + private boolean isSeatFree(int seat) { + if (seat < 0 || seat >= seatIds.length || seatIds[seat] != EMPTY) { + return false; + } + AircraftDefinition def = definition(); + if (def != null) { + for (int[] group : def.exclusiveSeats) { + if (contains(group, seat)) { + for (int other : group) { + if (other != seat && other >= 0 && other < seatIds.length && seatIds[other] != EMPTY) { + return false; + } + } + } + } + } + return true; + } + + private static boolean contains(int[] values, int value) { + for (int v : values) { + if (v == value) { + return true; + } + } + return false; + } + + /** First crew seat a player boarding by hand would take. Drones keep their pilot seat for the remote. */ + private int firstFreeCrewSeat() { + AircraftDefinition def = definition(); + if (def == null) { + return EMPTY; + } + for (int i = def.uav ? 1 : 0; i < def.seatCount(); i++) { + if (isSeatFree(i)) { + return i; + } + } + return EMPTY; + } + + private void assignSeat(Entity entity, int seat) { + for (int i = 0; i < seatIds.length; i++) { + if (seatIds[i] == entity.getId()) { + seatIds[i] = EMPTY; + if (weapons != null) { + weapons.onLeft(i); + } + } + } + if (seat >= 0 && seat < seatIds.length) { + seatIds[seat] = entity.getId(); + } + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onSeated(entity, seat); + weapons.resendAmmo(); + } + } + + private String encodeSeats() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < seatIds.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(seatIds[i]); + } + return sb.toString(); + } + + private void decodeSeats(String encoded) { + if (encoded.isEmpty()) { + Arrays.fill(seatIds, EMPTY); + return; + } + String[] parts = encoded.split(","); + if (parts.length != seatIds.length) { + seatIds = new int[parts.length]; + } + for (int i = 0; i < parts.length; i++) { + try { + seatIds[i] = Integer.parseInt(parts[i]); + } catch (NumberFormatException e) { + seatIds[i] = EMPTY; + } + } + } + + /** Moves {@code entity} to the next free crew seat, wrapping around. Server only. */ + public void switchSeat(Entity entity) { + AircraftDefinition def = definition(); + int current = seatOf(entity); + if (def == null || current < 0 || current >= def.seatCount()) { + return; + } + int count = def.seatCount(); + seatIds[current] = EMPTY; + for (int step = 1; step < count; step++) { + int seat = (current + step) % count; + if (def.uav && seat == 0) { + continue; + } + if (isSeatFree(seat)) { + assignSeat(entity, seat); + if (seat == 0) { + closeCanopy(); + } + return; + } + } + assignSeat(entity, current); + } + + @Override + public @Nullable LivingEntity getControllingPassenger() { + // A wreck is nobody's to fly; the server lets it fall, so its crash is seen the same way by everyone. + return occupant(0) instanceof Player player && !isDestroyed() ? player : null; + } + + @Override + protected boolean canAddPassenger(Entity passenger) { + AircraftDefinition def = definition(); + if (def == null || !def.canRide) { + return false; + } + if (pendingSeat >= 0) { + return isSeatFree(pendingSeat); + } + // Cargo only boards through a rack, never into a crew seat. + return !(passenger instanceof AircraftEntity) && firstFreeCrewSeat() >= 0; + } + + @Override + protected void addPassenger(Entity passenger) { + super.addPassenger(passenger); + if (level().isClientSide()) { + return; + } + int seat = pendingSeat; + pendingSeat = EMPTY; + if (seat < 0) { + Integer saved = savedSeats.remove(passenger.getUUID()); + seat = saved != null && isSeatFree(saved) ? saved : firstFreeCrewSeat(); + } + assignSeat(passenger, seat); + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (level().isClientSide()) { + return; + } + int seat = seatOf(passenger); + if (seat >= 0) { + lastSeats.put(passenger.getId(), seat); + seatIds[seat] = EMPTY; + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onLeft(seat); + } + } + if (seat == 0) { + input.clear(); + } + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + AircraftDefinition def = definition(); + int seat = seatOf(passenger); + if (def == null || seat < 0 || seat >= def.seatAndRackCount()) { + return super.getPassengerAttachmentPoint(passenger, dimensions, scale); + } + Vector3f p = seatPosition(def, seat, 1.0F); + return new Vec3(p.x, p.y, p.z); + } + + /** World offset of a seat from the vehicle origin. Turret seats turn with the pilot's view. */ + public Vector3f seatPosition(AircraftDefinition def, int seat, float partialTicks) { + Vector3 pos; + boolean turret; + if (seat < def.seatCount()) { + SeatDefinition s = def.seats.get(seat); + pos = s.position(); + turret = s.rotatingSeat(); + } else { + RackDefinition rack = def.rack(seat); + pos = rack.position(); + turret = rack.rotatingSeat(); + } + return toWorldOffset(turret ? onTurret(def, pos, partialTicks) : pos); + } + + /** Rotates a hull-space point around {@code TurretPosition} by the pilot's view yaw. */ + public Vector3 onTurret(AircraftDefinition def, Vector3 pos, float partialTicks) { + Vector3 t = def.turretPosition; + Vector3f v = AircraftRotation.toWorld(animation.seatYaw(0, partialTicks), 0.0F, 0.0F, + pos.x() - t.x(), pos.y() - t.y(), pos.z() - t.z()); + return new Vector3(v.x + t.x(), v.y + t.y(), v.z + t.z()); + } + + /** Rotates a hull-space point into the world frame, relative to the vehicle origin. */ + public Vector3f toWorldOffset(Vector3 local) { + return AircraftRotation.toWorld(getYRot(), getXRot(), roll(), local.x(), local.y(), local.z()); + } + + public Vec3 toWorld(Vector3 local) { + Vector3f v = toWorldOffset(local); + return position().add(v.x, v.y, v.z); + } + + @Override + protected void positionRider(Entity passenger, MoveFunction moveFunction) { + super.positionRider(passenger, moveFunction); + AircraftDefinition def = definition(); + if (def != null && passenger instanceof AircraftEntity cargo) { + RackDefinition rack = def.rack(seatOf(passenger)); + float yaw = rack != null ? rack.yaw() : 0.0F; + float pitch = rack != null ? rack.pitch() : 0.0F; + cargo.setYRot(getYRot() + yaw); + cargo.setXRot(getXRot() + pitch); + cargo.roll = roll(); + cargo.setDeltaMovement(Vec3.ZERO); + } + } + + @Override + public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + AircraftDefinition def = definition(); + Integer seat = lastSeats.get(passenger.getId()); + if (def != null) { + Vector3 local; + if (def.unmountPosition != null) { + local = def.unmountPosition; + } else { + Vector3 seatPos = seat != null && seat < def.seatCount() ? def.seats.get(seat).position() : def.seats.getFirst().position(); + double x = seatPos.x() >= 0.0 ? seatPos.x() + 3.0 : seatPos.x() - 3.0; + local = new Vector3(x, 2.0, seatPos.z()); + } + Vec3 at = toWorld(local); + AABB box = passenger.getDimensions(Pose.STANDING).makeBoundingBox(at); + if (level().noCollision(passenger, box)) { + return at; + } + } + return super.getDismountLocationForPassenger(passenger); + } + + /** + * 1.21.1 hands the click position to {@code interactAt}, which the client sends before the + * plain {@code interact}; 26.x merged the two. + */ + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + InteractionResult result = super.interact(player, hand); + if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { + return result; + } + AircraftDefinition def = definition(); + if (def == null || !def.canRide || player.getVehicle() == this) { + return InteractionResult.PASS; + } + boolean boardingPilot = !def.uav && isSeatFree(0); + if (boardingPilot && canopy != null && isCanopyClosed()) { + // The first click opens the canopy, the next one climbs in. + if (!level().isClientSide()) { + openCanopy(); + } + return InteractionResult.SUCCESS; + } + if (boardingPilot && modeSwitchCooldown > 0) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + if (!player.startRiding(this)) { + return InteractionResult.PASS; + } + if (seatOf(player) == 0) { + closeCanopy(); + } + } + return InteractionResult.SUCCESS; + } + + // Racks + + /** Loads {@code cargo} into the given rack seat. Server only. */ + private boolean loadCargo(AircraftEntity cargo, int seat) { + if (!isSeatFree(seat) || cargo.isPassenger()) { + return false; + } + pendingSeat = seat; + boolean loaded = cargo.startRiding(this, true); + pendingSeat = EMPTY; + if (loaded) { + level().playSound(null, getX(), getY(), getZ(), SoundEvents.UI_BUTTON_CLICK, SoundSource.NEUTRAL, 1.0F, 1.0F); + } + return loaded; + } + + /** Whether {@code cargo} may ride rack {@code seat} of this vehicle. */ + public boolean acceptsCargo(AircraftEntity cargo, int seat) { + AircraftDefinition def = definition(); + AircraftDefinition cargoDef = cargo.definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + if (rack == null || cargoDef == null || cargo == this || isPassenger() || cargo.isPassenger()) { + return false; + } + // A vehicle that carries cargo itself cannot be loaded. + for (Entity p : cargo.getPassengers()) { + if (p instanceof AircraftEntity) { + return false; + } + } + if (rack.accepts(cargoDef.name(), cargoDef.kind())) { + return true; + } + for (RideRack rr : cargoDef.rideRacks) { + if (rr.carrier().equalsIgnoreCase(def.name()) && def.seatCount() + rr.rack() - 1 == seat) { + return true; + } + } + return false; + } + + private boolean isNearRackEntry(AircraftEntity cargo, RackDefinition rack) { + Vec3 entry = toWorld(rack.entry()); + float r = rack.range(); + return Math.abs(cargo.getX() - entry.x) <= r && Math.abs(cargo.getY() - entry.y) <= r && Math.abs(cargo.getZ() - entry.z) <= r; + } + + /** Carrier pilot loads every nearby vehicle that fits a free rack. Server only. */ + public void loadNearbyCargo() { + AircraftDefinition def = definition(); + if (def == null || def.racks.isEmpty() || throttle > 0.3 || !hasBlockBelow(1, 3, true)) { + return; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (!isSeatFree(seat)) { + continue; + } + RackDefinition rack = def.rack(seat); + Vec3 entry = toWorld(rack.entry()); + AABB area = new AABB(entry, entry).inflate(rack.range()); + for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { + if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + break; + } + } + } + } + + /** Drops the last loaded piece of cargo at its rack's entry point. Server only. */ + public void unloadCargo() { + AircraftDefinition def = definition(); + if (def == null) { + return; + } + for (int seat = def.seatAndRackCount() - 1; seat >= def.seatCount(); seat--) { + Entity cargo = occupant(seat); + if (cargo == null) { + continue; + } + RackDefinition rack = def.rack(seat); + Vector3 drop = rack.entry(); + if (cargo instanceof AircraftEntity) { + // Aircraft roll out well clear of the carrier's tail or nose. + drop = drop.add(0.0, 0.0, drop.z() >= centreZ(def) ? 12.0 : -12.0); + } + Vec3 at = toWorld(drop); + cargo.stopRiding(); + cargo.moveTo(at.x, at.y, at.z, getYRot() + rack.yaw(), 0.0F); + return; + } + } + + /** Leaves the rack this vehicle rides on, at the rack entry point. Server only. */ + public void leaveRack() { + if (!(getVehicle() instanceof AircraftEntity carrier)) { + return; + } + AircraftDefinition carrierDef = carrier.definition(); + RackDefinition rack = carrierDef != null ? carrierDef.rack(carrier.seatOf(this)) : null; + Vec3 at = rack != null ? carrier.toWorld(rack.entry()) : position(); + stopRiding(); + moveTo(at.x, at.y, at.z, getYRot(), 0.0F); + } + + /** + * Pilot of a vehicle near a free rack of a carrier boards that rack. Server only. + * + * @return whether a rack was found + */ + public boolean boardNearbyRack() { + if (isPassenger()) { + return false; + } + for (AircraftEntity carrier : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(60.0))) { + AircraftDefinition def = carrier.definition(); + if (carrier == this || def == null) { + continue; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (carrier.isSeatFree(seat) && carrier.acceptsCargo(this, seat) && carrier.isNearRackEntry(this, def.rack(seat)) + && carrier.loadCargo(this, seat)) { + return true; + } + } + } + return false; + } + + private static double centreZ(AircraftDefinition def) { + if (def.boundingBoxes.isEmpty()) { + return 0.0; + } + double min = Double.MAX_VALUE; + double max = -Double.MAX_VALUE; + for (var box : def.boundingBoxes) { + min = Math.min(min, box.offset().z() - box.width() / 2.0); + max = Math.max(max, box.offset().z() + box.width() / 2.0); + } + return (min + max) / 2.0; + } + + // Ejection + + /** Seats zero and one of vehicles with {@code EnableEjectionSeat} can bail out. */ + public boolean canEject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && def.ejectionSeat && seat >= 0 && seat < 2 && !(seat == 0 && def.uav); + } + + /** + * Throws the occupant clear of the aircraft. Without the parachute item (a later phase) the + * crew member gets a few seconds of slow falling instead. Server only. + */ + public void eject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + if (def == null || !canEject(entity)) { + return; + } + Vector3 seatPos = def.seats.get(seat).position(); + Vec3 out = toWorld(seatPos.add(0.0, 2.0, 0.0)); + Vector3f push = toWorldOffset(new Vector3(0.0, 2.0, 0.0)); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement().add(push.x + (random.nextFloat() - 0.5) * 0.1, push.y, + push.z + (random.nextFloat() - 0.5) * 0.1)); + entity.hurtMarked = true; + if (entity instanceof LivingEntity living) { + living.addEffect(new MobEffectInstance(MobEffects.SLOW_FALLING, 400)); + } + if (canopy != null && !canopy.status()) { + canopy.set(true, false); + modeSwitchCooldown = 40; + } + ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); + } + + // Terrain helpers + + /** True if any solid block is within {@code depth} blocks below the vehicle. */ + protected boolean isNearGround(int depth) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + for (int i = 0; i < depth; i++) { + pos.move(0, -1, 0); + if (!level().getBlockState(pos).getCollisionShape(level(), pos).isEmpty()) { + return true; + } + } + return false; + } + + /** + * Port of {@code MCH_Lib.getBlockY}: scans a {@code size} x {@code size} column from the + * vehicle's block down {@code depth} layers (the first layer being the vehicle's own). + * + * @param solidOnly ignore blocks without collision; fluids still count when this is false + */ + public boolean hasBlockBelow(int size, int depth, boolean solidOnly) { + int px = Mth.floor(getX() + 0.5); + int py = Mth.floor(getY() + 0.5); + int pz = Mth.floor(getZ() + 0.5); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int y = 0; y < depth; y++) { + for (int x = -size / 2; x <= size / 2; x++) { + for (int z = -size / 2; z <= size / 2; z++) { + pos.set(px + x, py - y, pz + z); + BlockState state = level().getBlockState(pos); + if (state.isAir()) { + continue; + } + if (!solidOnly || !state.getCollisionShape(level(), pos).isEmpty() || !state.getFluidState().isEmpty()) { + return true; + } + } + } + } + return false; + } + + /** True if the first non-air block within {@code depth} below is water. */ + protected boolean isWaterBelow(int depth) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + for (int i = 0; i < depth; i++) { + BlockState state = level().getBlockState(pos); + if (!state.isAir()) { + return !state.getFluidState().isEmpty(); + } + pos.move(0, -1, 0); + } + return false; + } + + /** {@code getBlockY(this, 3, -2, false)}: standing on something that is neither air nor water. */ + protected boolean isOnSolidGround() { + int px = Mth.floor(getX() + 0.5); + int py = Mth.floor(getY() + 0.5); + int pz = Mth.floor(getZ() + 0.5); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int y = 0; y < 2; y++) { + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + BlockState state = level().getBlockState(pos.set(px + x, py - y, pz + z)); + if (!state.isAir()) { + return state.getFluidState().isEmpty(); + } + } + } + } + return false; + } + + /** + * Port of {@code getWaterDepth}: the share of the hit box that is in water, from 0 to 1, sampled + * in five horizontal slices. {@code FloatOffset} raises the slices, so a hull with an offset rides + * higher. The float physics only pushes up once this reaches 1, which sets the waterline. + * + *

The original drew the model {@value #LEGACY_MODEL_LIFT} above the bottom of the hit box + * (its {@code yOffset}, half the 0.7 default height); this port draws it at the bottom. The + * slices are lowered by the same amount so boats sit in the water as they did in 1.0.3. + */ + protected double waterDepth() { + if (entityData.get(DATA_SINKING)) { + return 0.0; + } + AircraftDefinition def = definition(); + float offset = (def != null ? def.floatOffset : 0.0F) - LEGACY_MODEL_LIFT; + AABB box = getBoundingBox(); + double height = box.maxY - box.minY; + double depth = 0.0; + for (int i = 0; i < 5; i++) { + double bottom = box.minY + height * i / 5.0 - 0.125 + offset; + double top = box.minY + height * (i + 1) / 5.0 - 0.125 + offset; + AABB slice = new AABB(box.minX, bottom, box.minZ, box.maxX, top, box.maxZ); + if (level().getBlockStates(slice).anyMatch(state -> state.getFluidState().is(FluidTags.WATER))) { + depth += 1.0 / 5.0; + } + } + return depth; + } + + /** {@code applyOnGroundPitch}: eases pitch towards {@code OnGroundPitch} and roll towards level. */ + protected void applyOnGroundPitch(float factor) { + AircraftDefinition def = definition(); + if (def != null) { + float ground = def.onGroundPitch; + setXRot((getXRot() - ground) * factor + ground); + } + roll *= factor; + } + + /** {@code StepHeight}: how tall a ledge the vehicle drives up without jumping. */ + @Override + public float maxUpStep() { + AircraftDefinition def = definition(); + return def != null ? def.stepHeight : 0.0F; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith() { + return true; + } + + @Override + public boolean isPushable() { + return false; + } + + // Weapons + + public @Nullable VehicleWeapons weapons() { + definition(); + return weapons; + } + + public void publishWeaponSelection() { + if (weapons != null && !level().isClientSide()) { + entityData.set(DATA_WEAPON_SELECTION, weapons.encodeSelection()); + } + } + + public void publishWeaponUse(int stat) { + entityData.set(DATA_WEAPON_USE, stat); + } + + public int syncedWeaponUse() { + return entityData.get(DATA_WEAPON_USE); + } + + /** + * Whether {@code set} fires along the user's view instead of the hull: the shot direction and + * the muzzle positions are worked out as if the vehicle faced where the user looks. Static + * mounts do this; see {@link GroundVehicleEntity#aimsWithUser}. + */ + public boolean aimsWithUser(WeaponSet set) { + return false; + } + + /** {@code isInfinityAmmo}: creative players never run dry, nor does anyone when the server config says so. */ + public boolean hasInfiniteAmmo(@Nullable Entity user) { + return ServerConfig.infiniteAmmo() || user instanceof Player player && player.getAbilities().instabuild; + } + + /** Where the pilot looks relative to the hull: the turret's traverse. */ + public float turretYaw() { + return animation.seatYaw(0, 1.0F); + } + + public float stealth() { + AircraftDefinition def = definition(); + return def != null ? def.stealth : 0.0F; + } + + /** The TV missile the crew is flying, if it still exists. */ + public @Nullable TvMissileEntity tvMissile() { + if (level().isClientSide()) { + return level().getEntity(entityData.get(DATA_TV_MISSILE)) instanceof TvMissileEntity m && !m.isRemoved() ? m : null; + } + return tvMissile != null && !tvMissile.isRemoved() ? tvMissile : null; + } + + public void setTvMissile(@Nullable TvMissileEntity missile) { + tvMissile = missile; + entityData.set(DATA_TV_MISSILE, missile != null ? missile.getId() : 0); + } + + /** Whether the flares are burning right now, for the crew's display. */ + public boolean isFlareActive() { + return entityData.get(DATA_FLARE); + } + + /** Someone is locking on to this vehicle; its crew hears the warning. Server only. */ + public void warnLocked() { + if (alertCount == 0 && !isDestroyed() && weapons != null && weapons.flares.isFitted()) { + alertCount = 10; + for (int seat = 0; seat < 2; seat++) { + if (occupant(seat) instanceof ServerPlayer player) { + ModSounds.playTo(player, "locked", 1.0F, 1.0F); + } + } + } + } + + // Hit boxes + + /** + * Clips a ray against the main hit box and every {@code BoundingBox}, remembering the damage + * factor of the box hit first, as the original's composite bounding box did. + */ + public Optional clipHitBoxes(Vec3 from, Vec3 to, double inflate) { + hitBoxFactor = 1.0F; + Optional best = clip(getBoundingBox().inflate(inflate), from, to); + double bestDistance = best.map(from::distanceToSqr).orElse(Double.MAX_VALUE); + AircraftDefinition def = definition(); + if (def != null) { + for (BoundingBoxDefinition box : def.boundingBoxes) { + Vec3 c = toWorld(box.offset()); + double hw = box.width() / 2.0 + inflate; + double hh = box.height() / 2.0 + inflate; + Optional hit = clip(new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw), from, to); + if (hit.isPresent() && from.distanceToSqr(hit.get()) < bestDistance) { + best = hit; + bestDistance = from.distanceToSqr(hit.get()); + hitBoxFactor = box.damageFactor(); + } + } + } + return best; + } + + /** A ray that starts inside a box hits it where it starts, as it does for vanilla entity picking. */ + private static Optional clip(AABB box, Vec3 from, Vec3 to) { + return box.contains(from) ? Optional.of(from) : box.clip(from, to); + } + + public float consumeHitBoxFactor() { + float f = hitBoxFactor; + hitBoxFactor = 1.0F; + return f; + } + + // Damage + + public boolean isDestroyed() { + return definition() != null && hp() <= 0; + } + + /** + * Port of the original's {@code attackEntityFrom}. Armour caps a hit at {@code ArmorMaxDamage}, + * scales it by {@code ArmorDamageFactor} and the struck box's factor, then subtracts + * {@code ArmorMinDamage}. A player punching an empty vehicle packs it up instead. + */ + @Override + public boolean hurt(DamageSource source, float damage) { + // 1.21.1 also calls this on the attacking client; only the server decides. + if (!(level() instanceof ServerLevel level)) { + return false; + } + float boxFactor = consumeHitBoxFactor(); + if (isRemoved() || isInvulnerableTo(source) || timeSinceHit > 0) { + return false; + } + if (source.is(DamageTypes.IN_FIRE) || source.is(DamageTypes.CACTUS)) { + return false; + } + boolean lava = source.is(DamageTypes.LAVA); + boolean onFire = source.is(DamageTypes.ON_FIRE); + if (lava) { + damage *= random.nextInt(8) + 2; + timeSinceHit = 2; + } + if (source.is(DamageTypeTags.IS_EXPLOSION)) { + timeSinceHit = 1; + } else if (source.getEntity() != null && hasPassenger(source.getEntity())) { + return false; + } + if (onFire) { + timeSinceHit = 10; + } + + boolean creative = false; + boolean sneaking = false; + boolean packUp = false; + if (source.getEntity() instanceof Player player) { + creative = player.getAbilities().instabuild; + sneaking = player.isShiftKeyDown(); + if (source.is(DamageTypes.PLAYER_ATTACK)) { + packUp = creative || !hasPlayerPassenger(); + } + ModSounds.playAt(this, "hit", 1.0F, 1.0F); + } else { + ModSounds.playAt(this, "helidmg", 1.0F, 0.9F + random.nextFloat() * 0.1F); + } + + if (!isDestroyed()) { + if (!packUp) { + AircraftDefinition def = definition(); + if (def != null && !lava && !onFire) { + damage = Math.min(damage, def.armorMaxDamage); + if (boxFactor <= 1.0F) { + damage *= boxFactor; + } + damage *= def.armorDamageFactor; + damage -= def.armorMinDamage; + if (damage <= 0.0F) { + return false; + } + if (boxFactor > 1.0F) { + damage *= boxFactor; + } + } + damage *= ServerConfig.vehicleDamageFactor(); + entityData.set(DATA_HP, Math.max(hp() - (int) damage, 0)); + } + setHurtDir(-getHurtDir()); + // No markHurt(): it sends the server's motion to every client, which snaps a vehicle + // its pilot is flying back a step and looks like knockback on every hit. + setHurtTime(10); + if (hp() <= 0 || packUp) { + if (!packUp) { + entityData.set(DATA_HP, 0); + destroyVehicle(); + timeSinceHit = 20; + if (isAfloat()) { + startCookOff(source.getEntity()); + } else if (source.is(DamageTypes.IN_WALL) || source.is(DamageTypes.FLY_INTO_WALL) || source.is(DamageTypes.FALL)) { + crashExplosion(); + } else { + Explosions.explode(level, getX(), getY(), getZ(), + new Explosions.Params(null, source.getEntity(), 2.0F, 2.0F, true, true, 5, false, null)); + } + } else { + // In creative the item only drops when sneaking, as with the original's defaults. + if (!creative || sneaking) { + spawnAtLocation(getPickResult() != null ? getPickResult() : ItemStack.EMPTY); + } + discard(); + } + } + } else if (packUp && creative) { + discard(); + } + return true; + } + + private boolean hasPlayerPassenger() { + for (Entity passenger : getPassengers()) { + if (passenger instanceof Player) { + return true; + } + } + return false; + } + + /** {@code destroyAircraft}: the wreck tumbles, burns and disappears after 30 seconds. Server only. */ + private void destroyVehicle() { + AircraftDefinition def = definition(); + despawnCount = def != null && def.targetDrone ? 50 : WRECK_TICKS; + destroyedPitch = random.nextFloat() - 0.5F; + destroyedRoll = (random.nextFloat() - 0.5F) * 0.5F; + destroyedYaw = 0.0F; + setHovering(false); + if (def != null && def.uav) { + Entity remote = occupant(0); + if (remote != null) { + remote.stopRiding(); + } + } + for (int seat = 0; seat < 2; seat++) { + Entity crew = occupant(seat); + if (crew != null && canEject(crew)) { + eject(crew); + } + } + } + + /** {@code explosionByCrash}: a wreck hitting the ground goes up in a fireball sized by its fuel. */ + private void crashExplosion() { + if (!(level() instanceof ServerLevel level)) { + return; + } + exploded = true; + AircraftDefinition def = definition(); + float size = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + Explosions.explode(level, getX(), getY(), getZ(), + new Explosions.Params(null, null, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + if (!isInWater()) { + Explosions.crashFireball(level, getX(), getY(), getZ(), size); + } + } + + /** A floating vehicle sitting on the water, such as a patrol boat or a float plane at rest. */ + private boolean isAfloat() { + AircraftDefinition def = definition(); + return def != null && def.floats && waterDepth() > 0.0; + } + + /** + * A boat destroyed on the water does not go up in one crash fireball; its fuel and magazine + * cook off in three blasts over about five seconds, then the hull sinks. Not in the original, + * which only had the crash explosion. + */ + private void startCookOff(@Nullable Entity cause) { + exploded = true; + cookOffBlasts = 3; + cookOffDelay = 1; + cookOffCause = cause; + } + + private void tickCookOff(ServerLevel level) { + if (cookOffBlasts <= 0 || --cookOffDelay > 0) { + return; + } + AircraftDefinition def = definition(); + int blast = 4 - cookOffBlasts--; + float fuel = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + // The fuel tank first, then whatever the magazine still holds, then the hull goes up. + float size = switch (blast) { + case 1 -> fuel * 0.6F; + case 2 -> Math.max(munitionSize(), fuel * 0.4F); + default -> fuel; + }; + size = Mth.clamp(size, 1.0F, 15.0F); + Vec3 at = cookOffPoint(def); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, cookOffCause, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + // The blasts happen at the waterline, but the fire is above it, so show the full fireball. + Explosions.crashFireball(level, at.x, at.y, at.z, size); + if (cookOffBlasts > 0) { + // Three blasts inside five seconds: about 1.5 to 2.5 seconds apart. + cookOffDelay = 30 + random.nextInt(20); + } else { + entityData.set(DATA_SINKING, true); + cookOffCause = null; + } + } + + /** + * A cooked-off wreck going under, in place of the vehicle's own physics: it settles slowly, + * stern first with the bow raised and listing to one side, drifts to a stop and comes to rest + * on the bottom. Out of the water it simply falls. + */ + private void sink() { + Vec3 m = getDeltaMovement(); + double my = isInWater() ? Math.max(m.y - 0.003, -SINK_SPEED) : m.y - 0.04; + Vec3 motion = new Vec3(m.x * 0.9, my, m.z * 0.9); + move(MoverType.SELF, motion); + setDeltaMovement(onGround() ? motion.multiply(0.5, 0.0, 0.5) : motion); + if (!onGround()) { + // Which way it lists only has to be the same on every side, so take it from the id. + float list = (getId() & 1) == 0 ? SINK_LIST : -SINK_LIST; + setXRot(Mth.approach(getXRot(), -SINK_PITCH, 0.12F)); + roll = Mth.approach(roll, list, 0.15F); + } + } + + /** + * How big the ammunition aboard goes up: each weapon's {@code Explosion} scaled by how full its + * magazine and reserve still are. Guns and empty racks add nothing. + */ + private float munitionSize() { + if (weapons == null) { + return 0.0F; + } + float size = 0.0F; + for (WeaponSet set : weapons.sets()) { + int rounds = set.ammo() + set.restAmmo(); + if (rounds > 0 && set.definition().explosion > 0) { + size += set.definition().explosion * Math.min(1.0F, (float) rounds / Math.max(set.maxAmmo(), 1)); + } + } + return size; + } + + /** A random spot on the hull: one of its hit boxes, or the middle of the body. */ + private Vec3 cookOffPoint(@Nullable AircraftDefinition def) { + if (def == null || def.boundingBoxes.isEmpty() || random.nextInt(def.boundingBoxes.size() + 1) == 0) { + return position().add(0.0, def != null ? def.bodyHeight / 2.0 : 0.5, 0.0); + } + return toWorld(def.boundingBoxes.get(random.nextInt(def.boundingBoxes.size())).offset()); + } + + /** Destroyed state each tick: the wreck spins, sets its crew alight and explodes on impact. */ + private void tickDestroyed() { + if (!isDestroyed()) { + return; + } + input.clear(); + throttle *= 0.98; + if (this instanceof HelicopterEntity) { + destroyedYaw = Math.min(destroyedYaw + 0.3F, 15.0F); + setYRot(getYRot() + destroyedYaw * (float) throttle); + if (!isNearGround(3)) { + if (Math.abs(getXRot()) < 10.0F) { + setXRot(getXRot() + destroyedPitch); + } + roll += destroyedRoll; + } + } + if (!(level() instanceof ServerLevel level)) { + return; + } + tickCookOff(level); + if (ticksAlive % 20 == 0) { + AircraftDefinition def = definition(); + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (crew != null && !(seat == 0 && def != null && def.uav)) { + crew.igniteForSeconds(5.0F); + } + } + } + if (despawnCount > 0 && --despawnCount <= 1) { + ejectPassengers(); + discard(); + } + } + + /** {@code onUpdate_Particle2}: smoke from a damaged hull, thicker the lower its hit points. */ + private void damageSmoke(AircraftDefinition def) { + float ratio = (float) hp() / def.maxHp; + if (ratio > 0.5F) { + return; + } + int sources = 1 + def.boundingBoxes.size(); + int pick = random.nextInt(sources); + Vec3 at = pick == 0 ? position().add(0.0, def.bodyHeight / 2.0, 0.0) : toWorld(def.boundingBoxes.get(pick - 1).offset()); + if (ratio < random.nextFloat() / 2.0F) { + level().addAlwaysVisibleParticle(ParticleTypes.LARGE_SMOKE, true, at.x, at.y, at.z, + (random.nextDouble() - 0.5) * 0.3, random.nextDouble() * 0.1, (random.nextDouble() - 0.5) * 0.3); + } + if (isDestroyed()) { + level().addAlwaysVisibleParticle(ParticleTypes.CAMPFIRE_COSY_SMOKE, true, at.x, at.y, at.z, 0.0, 0.07, 0.0); + if (random.nextInt(3) == 0) { + level().addParticle(ParticleTypes.FLAME, at.x + random.nextGaussian() * 0.5, at.y, at.z + random.nextGaussian() * 0.5, 0.0, 0.05, 0.0); + } + wreckFire(def); + } + } + + /** + * Not in the original: the wreck keeps burning until it disappears, with a fire glow that + * lights up the smoke at night and dies down over the last few seconds. Nothing burns under + * water. Client only. + */ + private void wreckFire(AircraftDefinition def) { + wreckTicks++; + if (isInWater()) { + return; + } + float left = Math.clamp((float) (WRECK_TICKS - wreckTicks) / WRECK_FADE_TICKS, 0.15F, 1.0F); + Vec3 centre = position().add(0.0, def.bodyHeight / 2.0, 0.0); + if (wreckTicks % 8 == 0) { + // Each glow outlives the gap to the next, so the fire never goes dark in between. + float size = Math.clamp(def.bodyWidth * 0.8F, 2.0F, 8.0F) * left; + level().addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.FIRE, size, 24), true, + centre.x, centre.y, centre.z, 0.0, 0.02, 0.0); + } + if (random.nextFloat() < left) { + double spread = def.bodyWidth * 0.4; + level().addParticle(ParticleTypes.FLAME, centre.x + random.nextGaussian() * spread, centre.y, + centre.z + random.nextGaussian() * spread, 0.0, 0.04, 0.0); + } + if (random.nextInt(10) == 0) { + level().addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.EMBER, 0.3F + random.nextFloat() * 0.3F, + 20 + random.nextInt(20)), true, centre.x, centre.y, centre.z, + (random.nextDouble() - 0.5) * 0.15, 0.1 + random.nextDouble() * 0.15, (random.nextDouble() - 0.5) * 0.15); + } + } + + @Override + public @Nullable ItemStack getPickResult() { + AircraftDefinition def = definition(); + return def != null ? AircraftItem.stackOf(def) : null; + } + + // Dimensions and syncing + + @Override + public EntityDimensions getDimensions(Pose pose) { + AircraftDefinition def = definition(); + return def != null ? EntityDimensions.scalable(def.bodyWidth, def.bodyHeight) : super.getDimensions(pose); + } + + @Override + public void onSyncedDataUpdated(EntityDataAccessor accessor) { + super.onSyncedDataUpdated(accessor); + if (DATA_TYPE.equals(accessor)) { + refreshDimensions(); + definition(); + } else if (DATA_SEATS.equals(accessor) && level().isClientSide()) { + decodeSeats(entityData.get(DATA_SEATS)); + } else if (DATA_WEAPON_SELECTION.equals(accessor) && level().isClientSide() && weapons() != null) { + weapons.decodeSelection(entityData.get(DATA_WEAPON_SELECTION), ClientAccess.localPlayer()); + } + } + + /** + * Server position updates are eased in over three ticks, as 26.x's linear interpolation does, + * with the step vanilla's boat uses on 1.21.1. + */ + @Override + public void lerpTo(double x, double y, double z, float yRot, float xRot, int steps) { + lerpX = x; + lerpY = y; + lerpZ = z; + lerpYRot = yRot; + lerpXRot = xRot; + lerpSteps = LERP_STEPS; + } + + @Override + public double lerpTargetX() { + return lerpSteps > 0 ? lerpX : getX(); + } + + @Override + public double lerpTargetY() { + return lerpSteps > 0 ? lerpY : getY(); + } + + @Override + public double lerpTargetZ() { + return lerpSteps > 0 ? lerpZ : getZ(); + } + + @Override + public float lerpTargetXRot() { + return lerpSteps > 0 ? (float) lerpXRot : getXRot(); + } + + @Override + public float lerpTargetYRot() { + return lerpSteps > 0 ? (float) lerpYRot : getYRot(); + } + + private void tickLerp() { + if (isControlledByLocalInstance()) { + lerpSteps = 0; + syncPacketPositionCodec(getX(), getY(), getZ()); + } + if (lerpSteps > 0) { + lerpPositionAndRotationStep(lerpSteps, lerpX, lerpY, lerpZ, lerpYRot, lerpXRot); + lerpSteps--; + } + } + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + tag.putString("Type", typeName()); + tag.putString("Texture", texture()); + tag.putInt("Hp", hp()); + tag.putFloat("Roll", roll); + tag.putBoolean("Hovering", isHoveringMode()); + tag.putBoolean("GunnerMode", isGunnerMode()); + tag.putInt("Parts", partBits()); + tag.putInt("DespawnCount", despawnCount); + tag.putInt("CookOff", cookOffBlasts); + tag.putBoolean("Sinking", entityData.get(DATA_SINKING)); + if (weapons != null) { + tag.putIntArray("WeaponsAmmo", weapons.saveAmmo()); + } + ListTag seats = new ListTag(); + for (int i = 0; i < seatIds.length; i++) { + Entity e = occupant(i); + seats.add(StringTag.valueOf(e != null ? e.getUUID().toString() : "")); + } + tag.put("Seats", seats); + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + entityData.set(DATA_TYPE, tag.getString("Type")); + entityData.set(DATA_TEXTURE, tag.getString("Texture")); + entityData.set(DATA_PARTS, tag.getInt("Parts")); + AircraftDefinition def = definition(); + entityData.set(DATA_HP, tag.contains("Hp") ? tag.getInt("Hp") : def != null ? def.maxHp : 1); + roll = tag.getFloat("Roll"); + setHovering(tag.getBoolean("Hovering")); + entityData.set(DATA_GUNNER_MODE, tag.getBoolean("GunnerMode")); + despawnCount = tag.getInt("DespawnCount"); + if (hp() <= 0 && despawnCount <= 0) { + despawnCount = WRECK_TICKS; + } + cookOffBlasts = tag.getInt("CookOff"); + cookOffDelay = cookOffBlasts > 0 ? 20 : 0; + entityData.set(DATA_SINKING, tag.getBoolean("Sinking")); + exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); + if (tag.contains("WeaponsAmmo", Tag.TAG_INT_ARRAY)) { + int[] ammo = tag.getIntArray("WeaponsAmmo"); + if (weapons != null) { + weapons.loadAmmo(ammo); + } else { + savedAmmo = ammo; + } + } + savedSeats.clear(); + ListTag seats = tag.getList("Seats", Tag.TAG_STRING); + for (int i = 0; i < seats.size(); i++) { + if (!seats.getString(i).isEmpty()) { + try { + savedSeats.put(UUID.fromString(seats.getString(i)), i); + } catch (IllegalArgumentException ignored) { + // A hand edited save; that occupant simply takes the first free seat. + } + } + } + for (PartState part : parts) { + part.force(part.status()); + } + refreshDimensions(); + } + + /** Raw pilot input, filled in by the client controller every tick. */ + public static final class ControlInput { + public boolean throttleUp; + public boolean throttleDown; + public boolean left; + public boolean right; + public boolean brake; + /** The pilot is looking around instead of steering; planes and tanks yaw with A/D then. */ + public boolean freeLook; + + public void clear() { + throttleUp = false; + throttleDown = false; + left = false; + right = false; + brake = false; + freeLook = false; + } + + public byte write() { + return (byte) ((throttleUp ? 1 : 0) | (throttleDown ? 2 : 0) | (left ? 4 : 0) | (right ? 8 : 0) + | (brake ? 16 : 0) | (freeLook ? 32 : 0)); + } + + public void read(byte bits) { + throttleUp = (bits & 1) != 0; + throttleDown = (bits & 2) != 0; + left = (bits & 4) != 0; + right = (bits & 8) != 0; + brake = (bits & 16) != 0; + freeLook = (bits & 32) != 0; + } + } + + public int getHurtTime() { + return entityData.get(DATA_HURT_TIME); + } + + public void setHurtTime(int ticks) { + entityData.set(DATA_HURT_TIME, ticks); + } + + public int getHurtDir() { + return entityData.get(DATA_HURT_DIR); + } + + public void setHurtDir(int dir) { + entityData.set(DATA_HURT_DIR, dir); + } + + /** The yaw for this frame, as 26.x's {@code Entity#getYRot(float)} gives it. */ + public float getYRot(float partialTicks) { + return partialTicks == 1.0F ? getYRot() : Mth.rotLerp(partialTicks, yRotO, getYRot()); + } + + /** The pitch for this frame, as 26.x's {@code Entity#getXRot(float)} gives it. */ + public float getXRot(float partialTicks) { + return partialTicks == 1.0F ? getXRot() : Mth.lerp(partialTicks, xRotO, getXRot()); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/PartState.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/PartState.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java new file mode 100644 index 0000000..926c678 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java @@ -0,0 +1,168 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.util.Mth; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import org.jspecify.annotations.Nullable; + +/** + * The A-10 that answers a close air support call, {@code MCH_EntityA10}. It dives in for a second, + * strafes the target with forty GAU-8 rounds and climbs away. + */ +public class A10Entity extends Entity { + private static final EntityDataAccessor DATA_WEAPON = SynchedEntityData.defineId(A10Entity.class, EntityDataSerializers.STRING); + private static final EntityDataAccessor DATA_DIRECTION = SynchedEntityData.defineId(A10Entity.class, EntityDataSerializers.INT); + private static final int LIFETIME = 70; + + private int despawnCount; + private int shotCount; + public int power = 32; + public int explosionPower = 1; + public float acceleration = 4.0F; + private @Nullable Entity shooter; + private @Nullable Entity user; + + public A10Entity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WEAPON, ""); + builder.define(DATA_DIRECTION, 0); + } + + /** + * @param direction 0 heading south (+Z), then clockwise in steps of 90 degrees + */ + public void setup(String weapon, int direction, double mx, double mz, @Nullable Entity shooter, @Nullable Entity user) { + entityData.set(DATA_WEAPON, weapon); + entityData.set(DATA_DIRECTION, direction); + setYRot(90 * direction); + yRotO = getYRot(); + setDeltaMovement(mx, 0.0, mz); + this.shooter = shooter; + this.user = user; + } + + public int direction() { + return entityData.get(DATA_DIRECTION); + } + + /** Hidden during the first second, while it is still out of sight. */ + public boolean isVisible() { + return despawnCount > 20; + } + + @Override + public void tick() { + super.tick(); + despawnCount++; + if (!level().isClientSide()) { + if (despawnCount > LIFETIME) { + discard(); + return; + } + if (shotCount < 40) { + for (int i = 0; i < 2; i++) { + fireGau8(shotCount); + shotCount++; + } + if (shotCount == 38) { + ModSounds.playAt(this, "gau-8_snd", 150.0F, 1.0F); + } + } + } + if (despawnCount <= 20) { + setDeltaMovement(getDeltaMovement().x, -0.3, getDeltaMovement().z); + } else { + setPos(getX() + getDeltaMovement().x, getY() + getDeltaMovement().y, getZ() + getDeltaMovement().z); + setDeltaMovement(getDeltaMovement().add(0.0, 0.02, 0.0)); + } + } + + private void fireGau8(int count) { + int dir = direction(); + double x = getX(); + double z = getZ(); + double tx = random.nextDouble() - 0.5; + double ty = -2.6; + double tz = random.nextDouble() - 0.5; + switch (dir) { + case 0 -> { + tz += 10.0; + z += count * 0.6; + } + case 1 -> { + tx -= 10.0; + x -= count * 0.6; + } + case 2 -> { + tz -= 10.0; + z -= count * 0.6; + } + default -> { + tx += 10.0; + x += count * 0.6; + } + } + BulletEntity round = ModEntities.BULLET.get().create(level()); + if (round == null) { + return; + } + round.launch(x, getY(), z, tx, ty, tz, 90 * dir, 30.0F, acceleration); + round.setWeapon(entityData.get(DATA_WEAPON)); + round.explosionPower = shotCount % 4 == 0 ? explosionPower : 0; + round.power = power; + round.setOwners(shooter, user); + level().addFreshEntity(round); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 512.0 * 512.0; + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } + + /** The yaw for this frame, as 26.x's {@code Entity#getYRot(float)} gives it. */ + public float getYRot(float partialTicks) { + return partialTicks == 1.0F ? getYRot() : Mth.rotLerp(partialTicks, yRotO, getYRot()); + } + + /** The pitch for this frame, as 26.x's {@code Entity#getXRot(float)} gives it. */ + public float getXRot(float partialTicks) { + return partialTicks == 1.0F ? getXRot() : Mth.lerp(partialTicks, xRotO, getXRot()); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java new file mode 100644 index 0000000..2fe1f43 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java @@ -0,0 +1,121 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseFireBlock; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +/** + * A canister that uses an item on every block around where it lands, {@code MCH_EntityDispensedItem}: + * water bombers put out fires, crop dusters spread bone meal or seeds, and so on. + */ +public class DispensedItemEntity extends ProjectileEntity { + public DispensedItemEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (w != null && !w.disableSmoke) { + trail(w.trajectoryParticle, 3, 7.0F * w.smokeSize); + } + if (!level().isClientSide() && w != null) { + Vec3 m = getDeltaMovement(); + if (acceleration < 1.0E-4) { + m = new Vec3(m.x * 0.999, m.y, m.z * 0.999); + } + if (isInWater()) { + m = m.scale(w.velocityInWater); + } + setDeltaMovement(m); + } + tickBomblets(); + } + + @Override + protected void impact(Impact hit, float damageFactor) { + if (!(level() instanceof ServerLevel server)) { + return; + } + WeaponDefinition w = weapon(); + Item item = w != null ? LegacyItems.item(w.dispenseItem, w.dispenseDamage) : null; + if (w != null && item != null && user() instanceof ServerPlayer player && !player.isRemoved()) { + BlockPos center = hit.block() != null ? hit.block() : blockPosition(); + int range = w.dispenseRange - 1; + for (int x = -range; x <= range; x++) { + for (int y = -range; y <= range; y++) { + for (int z = -range; z <= range; z++) { + int dist = x * x + y * y + z * z; + if (dist <= range * range && (dist <= 0.5 * range * range || random.nextInt(2) == 0)) { + useOn(server, center.offset(x, y, z), item); + } + } + } + } + } + discard(); + } + + private void useOn(ServerLevel server, BlockPos pos, Item item) { + if (pos.getY() < server.getMinBuildHeight() || pos.getY() >= server.getMaxBuildHeight() - 1) { + return; + } + BlockState state = server.getBlockState(pos); + if (state.isAir()) { + return; + } + if (item == Items.WATER_BUCKET) { + // A water drop douses fire and quenches lava instead of placing water. + FluidState fluid = server.getFluidState(pos); + if (state.getBlock() instanceof BaseFireBlock) { + server.removeBlock(pos, false); + } else if (fluid.is(FluidTags.LAVA)) { + server.setBlockAndUpdate(pos, fluid.isSource() ? Blocks.OBSIDIAN.defaultBlockState() : Blocks.COBBLESTONE.defaultBlockState()); + } + return; + } + // Used from above with nobody holding it, the way a dispenser block would. + BlockHitResult hit = new BlockHitResult(Vec3.atCenterOf(pos).add(0.0, 0.5, 0.0), Direction.UP, pos, false); + item.useOn(new UseOnContext(server, null, InteractionHand.MAIN_HAND, new ItemStack(item), hit)); + } + + @Override + protected void sprinkleBomblet() { + WeaponDefinition w = weapon(); + Vec3 m = getDeltaMovement(); + DispensedItemEntity child = spawnChild(ModEntities.DISPENSED_ITEM.get(), m.x, m.y, m.z, random.nextInt(360), 0.0F); + if (child == null || w == null) { + return; + } + float spread = w.bombletDiff; + child.setDeltaMovement(m.x + (random.nextFloat() - 0.5F) * spread, + m.y / 2.0 + (random.nextFloat() - 0.5F) * spread / 2.0F, + m.z + (random.nextFloat() - 0.5F) * spread); + child.setBomblet(); + level().addFreshEntity(child); + } + + @Override + public String defaultModel() { + return "bomb"; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java new file mode 100644 index 0000000..c8a9905 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java @@ -0,0 +1,159 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +/** + * A burning flare, or with a fuse a smoke grenade that bursts into a cloud. Port of + * {@code MCH_EntityFlare}. Flares only look the part: decoying happens in the dispenser. + */ +public class FlareEntity extends Entity { + private static final EntityDataAccessor DATA_FUSE = SynchedEntityData.defineId(FlareEntity.class, EntityDataSerializers.INT); + + private double gravity = -0.013; + private double airResistance = 0.992; + private float size = 6.0F; + + public FlareEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_FUSE, 0); + } + + /** + * @param fuse ticks until a smoke grenade bursts, 0 for a flare + * @param slow the long hanging type 4 flare + */ + public void launch(double x, double y, double z, double mx, double my, double mz, float size, int fuse, boolean slow) { + moveTo(x, y, z, random.nextFloat() * 360.0F, random.nextFloat() * 360.0F); + setDeltaMovement(mx, my, mz); + this.size = size; + entityData.set(DATA_FUSE, fuse); + if (slow) { + gravity *= 0.6; + airResistance = 0.995; + } + } + + private int fuse() { + return entityData.get(DATA_FUSE); + } + + /** A smoke grenade rather than a burning flare. */ + public boolean isSmoke() { + return fuse() > 0; + } + + @Override + public void tick() { + if (fuse() > 0 && tickCount >= fuse()) { + // Removal bursts the grenade on the client. + discard(); + return; + } + boolean server = !level().isClientSide(); + if (server && (!level().isLoaded(blockPosition()) || tickCount > 300)) { + discard(); + return; + } + super.tick(); + Vec3 m = getDeltaMovement(); + if (server) { + HitResult hit = level().clip(new ClipContext(position(), position().add(m), ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.MISS) { + discard(); + return; + } + } else { + trail(); + } + setPos(getX() + m.x, getY() + m.y, getZ() + m.z); + setDeltaMovement(m.x * airResistance, m.y + gravity, m.z * airResistance); + if (server && (isInWater() || onGround())) { + discard(); + } + } + + private void trail() { + double x = (getX() - xo) / 2.0; + double y = (getY() - yo) / 2.0; + double z = (getZ() - zo) / 2.0; + for (int i = 0; i < 2; i++) { + if (size < 5.0F && random.nextInt(2) == 0) { + continue; + } + level().addAlwaysVisibleParticle(fuse() > 0 ? ParticleTypes.CLOUD : ParticleTypes.LARGE_SMOKE, true, + xo + x * i, yo + y * i, zo + z * i, 0.0, 0.0, 0.0); + if (fuse() == 0) { + level().addAlwaysVisibleParticle(ParticleTypes.FLAME, true, xo + x * i, yo + y * i, zo + z * i, 0.0, 0.0, 0.0); + } + } + } + + /** A smoke grenade bursting into a cloud that hangs around for ten seconds or more. */ + private void burst() { + if (!level().isClientSide()) { + return; + } + for (int i = 0; i < 20; i++) { + level().addAlwaysVisibleParticle(ParticleTypes.CAMPFIRE_SIGNAL_SMOKE, true, + getX() + (random.nextDouble() - 0.5) * 10.0, + getY() + (random.nextDouble() - 0.5) * 10.0, + getZ() + (random.nextDouble() - 0.5) * 10.0, + (random.nextDouble() - 0.5) * 0.045, 0.005, (random.nextDouble() - 0.5) * 0.045); + } + } + + @Override + public void remove(RemovalReason reason) { + if (level().isClientSide() && fuse() > 0) { + burst(); + entityData.set(DATA_FUSE, 0); + } + super.remove(reason); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean isInvulnerable() { + return true; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + double d = getBoundingBox().getSize() * 4.0 * 64.0; + return distance < d * d; + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java new file mode 100644 index 0000000..7ccac6f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java @@ -0,0 +1,138 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.DustParticleOptions; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.Vec3; + +/** + * A marking rocket, {@code MCH_EntityMarkerRocket}. Where it lands under open sky it pops red + * smoke, and five seconds later a stick of bombs falls on the mark. + */ +public class MarkerRocketEntity extends ProjectileEntity { + private static final EntityDataAccessor DATA_STATUS = SynchedEntityData.defineId(MarkerRocketEntity.class, EntityDataSerializers.BYTE); + + /** 0 inert, 1 flying, 2 marking. */ + private int countDown; + + public MarkerRocketEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_STATUS, (byte) 0); + } + + public void setStatus(int status) { + if (!level().isClientSide()) { + entityData.set(DATA_STATUS, (byte) status); + } + } + + public int status() { + return entityData.get(DATA_STATUS); + } + + @Override + public void tick() { + int status = status(); + if (level().isClientSide()) { + if (weapon() == null) { + super.tick(); + } + WeaponDefinition w = weapon(); + if (w != null && !w.disableSmoke && status != 0) { + if (status == 1) { + super.tick(); + trail(w.trajectoryParticle, 3, 5.0F * w.smokeSize * 0.5F); + } else { + float gb = random.nextFloat() * 0.3F; + int rgb = (int) ((random.nextFloat() * 0.2F + 0.8F) * 255) << 16 | (int) (gb * 255) << 8 | (int) (gb * 255); + for (int i = 0; i < 5; i++) { + level().addAlwaysVisibleParticle(new DustParticleOptions(Vec3.fromRGB24(rgb).toVector3f(), 4.0F), true, getX(), getY(), getZ(), + (random.nextFloat() - 0.5F) * 0.7F, 0.3F + random.nextFloat() * 0.3F, (random.nextFloat() - 0.5F) * 0.7F); + } + } + } + } else if (status == 0 || isInWater()) { + discard(); + } else if (status == 1) { + super.tick(); + } else if (countDown > 0) { + countDown--; + if (countDown == 40) { + dropBombs(); + } + } else { + discard(); + } + } + + private void dropBombs() { + int count = 6 + random.nextInt(2); + for (int i = 0; i < count; i++) { + BombEntity bomb = ModEntities.BOMB.get().create(level()); + if (bomb == null) { + continue; + } + bomb.launch(getX() + (random.nextFloat() - 0.5F) * 15.0F, 260.0F + random.nextFloat() * 10.0F + i * 30, + getZ() + (random.nextFloat() - 0.5F) * 15.0F, 0.0, -0.5, 0.0, 0.0F, 90.0F, 4.0); + bomb.setWeapon(weaponName()); + bomb.explosionPower = 3 + random.nextInt(2); + bomb.explosionPowerInWater = 0; + bomb.power = 30; + bomb.piercing = 0; + bomb.setOwners(shooter, user); + level().addFreshEntity(bomb); + } + } + + @Override + protected void impact(Impact hit, float damageFactor) { + if (level().isClientSide() || hit.entity() != null || hit.block() == null) { + return; + } + BlockPos mark = hit.block().relative(hit.side()); + if (!level().getBlockState(mark).isAir()) { + discard(); + return; + } + level().setBlockAndUpdate(mark, Blocks.FIRE.defaultBlockState()); + int solid = 0; + for (int y = mark.getY() + 1; y < level().getMaxBuildHeight() - 1; y++) { + if (!level().getBlockState(new BlockPos(mark.getX(), y, mark.getZ())).isAir() && ++solid >= 5) { + break; + } + } + if (solid < 5) { + setStatus(2); + setPos(mark.getX() + 0.5, mark.getY() + 1.1, mark.getZ() + 0.5); + xo = getX(); + yo = getY(); + zo = getZ(); + countDown = 100; + } else { + discard(); + } + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public String defaultModel() { + return "rocket"; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java new file mode 100644 index 0000000..3ffc508 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -0,0 +1,764 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.weapon.Combat; +import com.jnhprojects.mcheli.weapon.Explosions; +import com.jnhprojects.mcheli.weapon.ModDamageTypes; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Mth; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Everything a weapon fires: rounds, rockets, missiles, bombs and torpedoes. A port of + * {@code MCH_EntityBaseBullet}. + * + *

Projectiles fly on both sides, the client only for looks; hits, fuses and guidance are decided + * on the server. They are never saved: like the original, one found in a loaded chunk vanishes. + */ +public abstract class ProjectileEntity extends Entity { + private static final EntityDataAccessor DATA_WEAPON = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.STRING); + private static final EntityDataAccessor DATA_TARGET = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_BOMBLET = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.BOOLEAN); + + /** Beyond 5820 blocks from whoever fired it, a projectile is dropped. */ + private static final double MAX_RANGE_SQ = 3.38724E7; + + protected @Nullable Entity shooter; + protected @Nullable Entity user; + protected @Nullable Entity target; + private @Nullable WeaponDefinition weapon; + + public int explosionPower; + public int explosionPowerInWater; + public int power = 1; + public int piercing; + public int delayFuse; + public int sprinkleTime; + public double acceleration = 1.0; + public double accelerationFactor = 1.0; + private int age; + + public Vec3 prevMotion = Vec3.ZERO; + /** Position two ticks ago, used to spread trail particles over the distance covered. */ + protected double xo2; + protected double yo2; + protected double zo2; + + protected ProjectileEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WEAPON, ""); + builder.define(DATA_TARGET, 0); + builder.define(DATA_BOMBLET, false); + } + + /** + * The second constructor of the original: place the projectile and point it along + * {@code (mx, my, mz)} at {@code acceleration} blocks per tick, capped at 3.9. + */ + public void launch(double x, double y, double z, double mx, double my, double mz, float yaw, float pitch, double acceleration) { + moveTo(x, y, z, yaw, pitch); + xo2 = x; + yo2 = y; + zo2 = z; + double a = Math.min(acceleration, 3.9); + double d = Math.sqrt(mx * mx + my * my + mz * mz); + Vec3 motion = d > 0.0 ? new Vec3(mx * a / d, my * a / d, mz * a / d) : Vec3.ZERO; + setDeltaMovement(motion); + prevMotion = motion; + this.acceleration = a; + } + + // Weapon + + public void setWeapon(String name) { + WeaponDefinition def = MCHeli.content().weapon(name); + if (def == null) { + return; + } + weapon = def; + if (!level().isClientSide()) { + entityData.set(DATA_WEAPON, name); + } + if (def.bomblet > 0) { + sprinkleTime = def.bombletSTime; + } + piercing = def.piercing; + if (boostsAcceleration() && def.acceleration > 4.0F) { + accelerationFactor = def.acceleration / 4.0F; + } + } + + /** Rounds and rockets faster than 4 blocks a tick move several steps per tick instead. */ + protected boolean boostsAcceleration() { + return false; + } + + public @Nullable WeaponDefinition weapon() { + return weapon; + } + + /** Model drawn when the weapon names none, one of the original's {@code MCH_DefaultBulletModels}. */ + public abstract String defaultModel(); + + /** The model to draw: {@code ModelBomblet} for bomblets, else {@code ModelBullet}, else the default. */ + public String model() { + if (weapon == null) { + return defaultModel(); + } + String name = isBomblet() ? weapon.bombletModel : weapon.bulletModel; + return name.isEmpty() ? defaultModel() : name; + } + + public String weaponName() { + return entityData.get(DATA_WEAPON); + } + + /** {@code setParameterFromWeapon}: power and charges from the weapon file, and who to credit. */ + public void setParameters(WeaponDefinition def, @Nullable Entity shooter, @Nullable Entity user) { + explosionPower = def.explosion; + explosionPowerInWater = def.explosionInWater; + power = def.power; + piercing = def.piercing; + this.shooter = shooter; + this.user = user; + } + + /** The same, inherited from a parent projectile, for bomblets. */ + public void setParameters(ProjectileEntity parent) { + explosionPower = parent.explosionPower; + explosionPowerInWater = parent.explosionPowerInWater; + power = parent.power; + piercing = parent.piercing; + shooter = parent.shooter; + user = parent.user; + } + + public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + } + + public @Nullable Entity user() { + return user; + } + + public @Nullable Entity shooter() { + return shooter; + } + + public boolean isBomblet() { + return entityData.get(DATA_BOMBLET); + } + + public void setBomblet() { + sprinkleTime = 0; + entityData.set(DATA_BOMBLET, true); + } + + public void setTarget(@Nullable Entity entity) { + target = entity; + if (!level().isClientSide()) { + entityData.set(DATA_TARGET, entity != null ? entity.getId() : 0); + } + } + + public @Nullable Entity target() { + return target; + } + + /** A flare seduced this missile: it loses its target and is gone. */ + public void decoy() { + target = null; + discard(); + } + + public int age() { + return age; + } + + public float gravity() { + return weapon != null ? weapon.gravity : 0.0F; + } + + public float gravityInWater() { + return weapon != null ? weapon.gravityInWater : 0.0F; + } + + // Tick + + @Override + public void tick() { + if (level().isClientSide() && age == 0) { + int targetId = entityData.get(DATA_TARGET); + if (targetId > 0) { + target = level().getEntity(targetId); + } + } + Vec3 motion = getDeltaMovement(); + if (!motion.equals(prevMotion)) { + faceMotion(); + } + prevMotion = motion; + age++; + xo2 = xo; + yo2 = yo; + zo2 = zo; + super.tick(); + + if (weapon == null) { + if (age >= 2) { + discard(); + return; + } + setWeapon(weaponName()); + if (weapon == null) { + return; + } + } + + if (!level().isClientSide()) { + if (getY() <= level().getMaxBuildHeight() - 1 && !level().isLoaded(blockPosition())) { + if (weapon.delayFuse <= 0) { + discard(); + return; + } + if (delayFuse == 0) { + delayFuse = weapon.delayFuse; + } + } + if (delayFuse > 0) { + delayFuse--; + if (delayFuse == 0) { + onTimeout(); + discard(); + return; + } + } + if (!isValid()) { + discard(); + return; + } + if (weapon.timeFuse > 0 && age > weapon.timeFuse) { + onTimeout(); + discard(); + return; + } + if (weapon.explosionAltitude > 0 && isGroundWithin(weapon.explosionAltitude)) { + impact(new Impact(position(), null, null, Direction.DOWN), 1.0F); + } + } + + motion = getDeltaMovement(); + setDeltaMovement(motion.x, motion.y + (isInWater() ? gravityInWater() : gravity()), motion.z); + if (!isRemoved()) { + checkCollisions(); + } + motion = getDeltaMovement().scale(accelerationFactor); + double nx = getX() + motion.x; + double ny = getY() + motion.y; + double nz = getZ() + motion.z; + if (level().isClientSide()) { + splash(nx, ny, nz); + } + if (isInWater()) { + Vec3 m = getDeltaMovement(); + level().addParticle(ParticleTypes.BUBBLE, nx - m.x * 0.25, ny - m.y * 0.25, nz - m.z * 0.25, m.x, m.y, m.z); + } + setPos(nx, ny, nz); + } + + /** Turns the nose along the flight path, the way every projectile class did after moving. */ + protected void faceMotion() { + Vec3 m = getDeltaMovement(); + setYRot((float) Math.toDegrees(Math.atan2(m.z, m.x)) - 90.0F); + setXRot(-(float) Math.toDegrees(Math.atan2(m.y, Math.sqrt(m.x * m.x + m.z * m.z)))); + } + + /** {@code setMotion}: flies straight along {@code (dx, dy, dz)} at full speed. */ + protected void aimAlong(double dx, double dy, double dz) { + double d = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (d > 0.0) { + setDeltaMovement(dx * acceleration / d, dy * acceleration / d, dz * acceleration / d); + } + } + + /** {@code guidanceToTarget}: turns a seventh of the way towards the target each tick. */ + protected void guideTo(double tx, double ty, double tz) { + double dx = tx - getX(); + double dy = ty - getY(); + double dz = tz - getZ(); + double d = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (d <= 0.0) { + return; + } + Vec3 m = getDeltaMovement(); + setDeltaMovement((m.x * 6.0 + dx * acceleration / d) / 7.0, + (m.y * 6.0 + dy * acceleration / d) / 7.0, + (m.z * 6.0 + dz * acceleration / d) / 7.0); + faceMotion(); + } + + /** {@code checkValid}: whoever fired it still exists and is not too far away. */ + private boolean isValid() { + if (shooter == null && user == null) { + return false; + } + if (user != null && user.isRemoved()) { + return false; + } + Entity origin = user != null ? user : shooter; + double dx = getX() - origin.getX(); + double dz = getZ() - origin.getZ(); + return dx * dx + dz * dz < MAX_RANGE_SQ; + } + + private boolean isGroundWithin(int depth) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + for (int i = 0; i < depth; i++) { + pos.move(Direction.DOWN); + if (!level().getBlockState(pos).isAir()) { + return true; + } + } + return false; + } + + /** Spawns {@code count} trail particles spread over the last two ticks of flight. */ + protected void trail(String name, int count, float size) { + if (!level().isClientSide() || name.isEmpty() || count < 1 || count > 50) { + return; + } + ParticleOptions particle = trailParticle(name); + double x = (getX() - xo) / count; + double y = (getY() - yo) / count; + double z = (getZ() - zo) / count; + double x2 = (xo - xo2) / count; + double y2 = (yo - yo2) / count; + double z2 = (zo - zo2) / count; + for (int i = 0; i < count; i++) { + double px = (xo + x * i + xo2 + x2 * i) / 2.0; + double py = (yo + y * i + yo2 + y2 * i) / 2.0; + double pz = (zo + z * i + zo2 + z2 * i) / 2.0; + level().addAlwaysVisibleParticle(particle, true, px, py, pz, 0.0, 0.0, 0.0); + } + } + + /** + * The original's own smoke particle ({@code explode}) was a large white puff; the cloud is the + * closest vanilla one. Other names are 1.7.10 particle names. + */ + private static ParticleOptions trailParticle(String name) { + return switch (name) { + case "explode", "cloud" -> ParticleTypes.CLOUD; + case "flame" -> ParticleTypes.FLAME; + case "largesmoke" -> ParticleTypes.LARGE_SMOKE; + case "heart" -> ParticleTypes.HEART; + case "fireworksspark" -> ParticleTypes.FIREWORK; + default -> ParticleTypes.SMOKE; + }; + } + + private void splash(double nx, double ny, double nz) { + if (weapon == null || weapon.power <= 0) { + return; + } + boolean wasDry = !level().getFluidState(BlockPos.containing(xo + 0.5, yo + 0.5, zo + 0.5)).is(FluidTags.WATER); + if (wasDry && level().getFluidState(BlockPos.containing(nx + 0.5, ny + 0.5, nz + 0.5)).is(FluidTags.WATER)) { + float pwr = Math.min(weapon.power, 20); + int n = random.nextInt(1 + (int) pwr / 3) + (int) pwr / 2 + 1; + for (int i = 0; i < n; i++) { + level().addParticle(ParticleTypes.SPLASH, nx, ny + 0.5, nz, + pwr * 0.03 * (random.nextDouble() - 0.5) * 0.3, + pwr * 0.03 * (random.nextDouble() * 0.5 + 0.5) * 1.8, + pwr * 0.03 * (random.nextDouble() - 0.5) * 0.3); + } + } + } + + /** {@code onUpdateTimeout}: the fuse ran out in mid air. */ + protected void onTimeout() { + if (!(level() instanceof ServerLevel server) || weapon == null) { + return; + } + if (isInWater()) { + if (explosionPowerInWater > 0) { + explode(server, getX(), getY(), getZ(), explosionPowerInWater, explosionPowerInWater, true); + } + } else if (explosionPower > 0) { + explode(server, getX(), getY(), getZ(), explosionPower, weapon.explosionBlock, false); + } else if (explosionPower < 0) { + Explosions.playSound(level(), getX(), getY(), getZ()); + } + } + + /** {@code onUpdateBomblet}: once the sprinkle timer runs out the carrier splits up. */ + protected void tickBomblets() { + if (!level().isClientSide() && sprinkleTime > 0 && !isRemoved() && weapon != null) { + sprinkleTime--; + if (sprinkleTime == 0) { + for (int i = 0; i < weapon.bomblet; i++) { + sprinkleBomblet(); + } + discard(); + } + } + } + + protected void sprinkleBomblet() { + } + + /** Creates a copy of this projectile's kind for a bomblet, flying from here. */ + protected @Nullable T spawnChild(EntityType type, double mx, double my, double mz, float yaw, float pitch) { + T child = type.create(level()); + if (child == null) { + return null; + } + child.launch(getX(), getY(), getZ(), mx, my, mz, yaw, pitch, acceleration); + child.setWeapon(weaponName()); + child.setParameters(this); + return child; + } + + // Collision + + /** Where something was hit. {@code entity} is set for entity hits, {@code block} for block hits. */ + public record Impact(Vec3 location, @Nullable Entity entity, @Nullable BlockPos block, Direction side) { + } + + /** {@code onUpdateCollided}: the swept ray of this tick against blocks, then entities. */ + protected void checkCollisions() { + if (weapon == null) { + return; + } + Vec3 motion = getDeltaMovement().scale(accelerationFactor); + Vec3 from = position(); + Vec3 to = from.add(motion); + BlockHitResult blockHit = null; + for (int i = 0; i < 5; i++) { + blockHit = level().clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (blockHit.getType() != HitResult.Type.BLOCK) { + blockHit = null; + break; + } + BlockState state = level().getBlockState(blockHit.getBlockPos()); + if (user == null || level().isClientSide() || !Combat.isBulletBreakable(state)) { + break; + } + level().destroyBlock(blockHit.getBlockPos(), true, user); + } + + if (weapon.delayFuse > 0) { + if (blockHit != null) { + bounce(blockHit.getDirection()); + if (delayFuse == 0) { + delayFuse = weapon.delayFuse; + } + } + return; + } + + if (blockHit != null) { + to = blockHit.getLocation(); + } + Impact entityHit = findEntity(from, to, motion); + if (entityHit != null) { + // A vehicle applies the factor of the box struck itself, when it takes the damage. + impact(entityHit, 1.0F); + } else if (blockHit != null) { + impact(new Impact(blockHit.getLocation(), null, blockHit.getBlockPos(), blockHit.getDirection()), 1.0F); + } + } + + private @Nullable Impact findEntity(Vec3 from, Vec3 to, Vec3 motion) { + AABB area = getBoundingBox().expandTowards(motion).inflate(21.0); + Entity best = null; + Vec3 bestAt = null; + double bestDistance = 0.0; + List clipped = new ArrayList<>(); + for (Entity candidate : level().getEntities(this, area, this::canHit)) { + Optional at; + if (candidate instanceof AircraftEntity vehicle) { + at = vehicle.clipHitBoxes(from, to, 0.3); + clipped.add(vehicle); + } else { + at = candidate.getBoundingBox().inflate(0.3).clip(from, to); + } + if (at.isPresent()) { + double d = from.distanceTo(at.get()); + if (best == null || d < bestDistance) { + best = candidate; + bestAt = at.get(); + bestDistance = d; + } + } + } + // Only the vehicle actually hit keeps the factor of the box the ray struck. + for (AircraftEntity vehicle : clipped) { + if (vehicle != best) { + vehicle.consumeHitBoxFactor(); + } + } + return best != null ? new Impact(bestAt, best, null, Direction.UP) : null; + } + + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ + public boolean canHit(Entity entity) { + if (!entity.isPickable() || entity == user || entity == shooter) { + return false; + } + if (entity instanceof ProjectileEntity other) { + if (level().isClientSide()) { + return false; + } + if (other.shooter != null && other.shooter == shooter || other.user != null && other.user == user) { + return false; + } + } + return !(shooter instanceof AircraftEntity vehicle) || !vehicle.hasPassenger(entity); + } + + /** {@code boundBullet}: a delayed fuse round bounces off what it hits. */ + private void bounce(Direction side) { + Vec3 m = getDeltaMovement(); + float bound = weapon != null ? weapon.bound : 0.0F; + switch (side) { + case DOWN -> { + if (m.y > 0.0) { + setDeltaMovement(m.x, -m.y * bound, m.z); + } + } + case UP -> { + if (m.y < 0.0) { + setDeltaMovement(m.x, -m.y * bound, m.z); + } + } + case NORTH -> { + if (m.z > 0.0) { + setDeltaMovement(m.x, m.y, -m.z * bound); + } else { + setPos(getX(), getY(), getZ() + m.z); + } + } + case SOUTH -> { + if (m.z < 0.0) { + setDeltaMovement(m.x, m.y, -m.z * bound); + } else { + setPos(getX(), getY(), getZ() + m.z); + } + } + case WEST -> { + if (m.x > 0.0) { + setDeltaMovement(-m.x * bound, m.y, m.z); + } else { + setPos(getX() + m.x, getY(), getZ()); + } + } + case EAST -> { + if (m.x < 0.0) { + setDeltaMovement(-m.x * bound, m.y, m.z); + } else { + setPos(getX() + m.x, getY(), getZ()); + } + } + } + } + + /** {@code onImpact}: damage what was hit, then explode or pierce through. */ + protected void impact(Impact hit, float damageFactor) { + if (!(level() instanceof ServerLevel server)) { + if (weapon != null && (weapon.explosion == 0 || weapon.modeNum >= 2) && hit.block() != null) { + debris(hit); + } + return; + } + if (weapon == null) { + discard(); + return; + } + if (hit.entity() != null) { + hitEntity(hit.entity(), damageFactor); + piercing = 0; + } + float expPower = explosionPower * damageFactor; + float expPowerInWater = explosionPowerInWater * damageFactor; + Vec3 at = hit.location(); + if (piercing > 0) { + piercing--; + if (expPower > 0.0F) { + explode(server, at.x, at.y, at.z, 1.0F, 1.0F, false); + } + return; + } + if (expPowerInWater == 0.0F) { + if (weapon.fae) { + explodeFae(server, expPower); + } else if (expPower > 0.0F) { + explode(server, at.x, at.y, at.z, expPower, weapon.explosionBlock, false); + } else if (expPower < 0.0F) { + Explosions.playSound(level(), getX(), getY(), getZ()); + } + } else if (hit.entity() != null) { + if (isInWater()) { + explode(server, at.x, at.y, at.z, expPowerInWater, expPowerInWater, true); + } else { + explode(server, at.x, at.y, at.z, expPower, weapon.explosionBlock, false); + } + } else if (isInWater() || hit.block() != null && isWaterAround(hit.block())) { + BlockPos b = hit.block() != null ? hit.block() : blockPosition(); + explode(server, b.getX(), b.getY(), b.getZ(), expPowerInWater, expPowerInWater, true); + } else if (expPower > 0.0F) { + explode(server, at.x, at.y, at.z, expPower, weapon.explosionBlock, false); + } else if (expPower < 0.0F) { + Explosions.playSound(level(), getX(), getY(), getZ()); + } + discard(); + } + + /** {@code MCH_Lib.isBlockInWater}: water on any side of the block. */ + private boolean isWaterAround(BlockPos pos) { + for (Direction d : Direction.values()) { + if (level().getFluidState(pos.relative(d)).is(FluidTags.WATER)) { + return true; + } + } + return level().getFluidState(pos).is(FluidTags.WATER); + } + + /** Client side chips flying off the block a round struck. */ + private void debris(Impact hit) { + if (weapon == null || hit.block() == null) { + return; + } + BlockState state = level().getBlockState(hit.block()); + if (state.isAir()) { + return; + } + float p = weapon.power; + Vec3 m = getDeltaMovement(); + var particle = new BlockParticleOption(ParticleTypes.BLOCK, state); + for (int i = 0; i < p / 3.0F; i++) { + level().addParticle(particle, + hit.location().x + (random.nextFloat() - 0.5) * p / 10.0, + hit.location().y + 0.1, + hit.location().z + (random.nextFloat() - 0.5) * p / 10.0, + -m.x * p / 2.0, p / 2.0F, -m.z * p / 2.0); + } + } + + /** {@code onImpactEntity}. */ + protected void hitEntity(Entity entity, float damageFactor) { + if (!entity.isRemoved()) { + DamageSource source = ModDamageTypes.bullet(level(), this, user); + Combat.hurt(entity, source, power * damageFactor, weapon); + } + confirmHit(); + } + + /** {@code notifyHitBullet}: the shooter hears that a round connected. */ + protected void confirmHit() { + if (user instanceof ServerPlayer player) { + ModSounds.playTo(player, "hit", 1.0F, 1.0F); + } + } + + protected void explode(ServerLevel server, double x, double y, double z, float size, float blockSize, boolean inWater) { + if (weapon == null) { + return; + } + boolean destroyBlocks = !isBomblet() || random.nextInt(3) == 0; + Explosions.Params p = new Explosions.Params(this, user, size, blockSize, weapon.flaming, destroyBlocks, 0, inWater, weapon); + if (Explosions.explode(server, x, y, z, p)) { + confirmHit(); + } + } + + /** Fuel air explosives burn whatever they catch. */ + private void explodeFae(ServerLevel server, float size) { + Explosions.Params p = new Explosions.Params(this, user, size, weapon.explosionBlock, weapon.flaming, true, 15, false, weapon); + if (Explosions.explode(server, getX(), getY(), getZ(), p)) { + confirmHit(); + } + } + + // Entity plumbing + + @Override + public boolean hurt(DamageSource source, float damage) { + // Something thrown at a projectile sets it off, as in the original. + if (damage > 0.0F && source.is(DamageTypes.THROWN) && !isRemoved()) { + impact(new Impact(position().add(0.5, 0.5, 0.5), null, blockPosition(), Direction.UP), 1.0F); + return true; + } + return false; + } + + @Override + public boolean isPickable() { + return true; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + double d = getBoundingBox().getSize() * 4.0 * 64.0; + return distance < d * d; + } + + @Override + protected void readAdditionalSaveData(CompoundTag tag) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + tag.putString("WeaponName", weaponName()); + } + + @Override + public boolean shouldBeSaved() { + return false; + } + + /** The yaw for this frame, as 26.x's {@code Entity#getYRot(float)} gives it. */ + public float getYRot(float partialTicks) { + return partialTicks == 1.0F ? getYRot() : Mth.rotLerp(partialTicks, yRotO, getYRot()); + } + + /** The pitch for this frame, as 26.x's {@code Entity#getXRot(float)} gives it. */ + public float getXRot(float partialTicks) { + return partialTicks == 1.0F ? getXRot() : Mth.lerp(partialTicks, xRotO, getXRot()); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 0000000..f7258aa --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,126 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +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.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.CustomModelData; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Places a vehicle, the same way a boat is placed. + * + *

There is one item per vehicle kind ({@code mcheli:helicopter}, {@code mcheli:plane} and so + * on). Which vehicle a stack places is stored in its {@link ModDataComponents#VEHICLE} component, + * and its look comes from the {@code item_model} component pointing at the model the import wrote + * for that vehicle. Items are registered once at startup, so this is what lets definitions be + * added or reloaded while the game runs. + */ +public class AircraftItem extends Item { + private final String kind; + + public AircraftItem(String kind, Properties properties) { + super(properties); + this.kind = kind; + } + + /** The vehicle kind this item holds, such as {@code helicopter}. */ + public String kind() { + return kind; + } + + /** + * A stack that places {@code def}. Vehicles from the import show the item model the import + * wrote for them, picked by the kind model's {@code custom_model_data} overrides (1.21.1 has no + * {@code item_model} component); item models only load with the resources, so pack vehicles keep + * their kind's own model. + */ + public static ItemStack stackOf(AircraftDefinition def) { + ItemStack stack = new ItemStack(ModItems.forKind(def.kind())); + stack.set(ModDataComponents.VEHICLE.get(), def.name()); + if (def.pack().equals(ContentSource.ORIGINAL)) { + MCHeli.legacyContent().iconIndex(def.name()) + .ifPresent(index -> stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(index))); + } + return stack; + } + + /** The definition name a stack places, or {@code null} if it is not a vehicle item or has none. */ + public static @Nullable String vehicleOf(ItemStack stack) { + return stack.getItem() instanceof AircraftItem item ? item.definitionName(stack) : null; + } + + protected @Nullable String definitionName(ItemStack stack) { + return stack.get(ModDataComponents.VEHICLE.get()); + } + + @Override + public Component getName(ItemStack stack) { + String name = definitionName(stack); + if (name == null) { + return super.getName(stack); + } + AircraftDefinition def = MCHeli.content().any(name); + // Vehicles added at runtime may bring no lang entry; their DisplayName stands in. + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { + return InteractionResultHolder.pass(stack); + } + + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + if (!level.isClientSide()) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.unknown", String.valueOf(name)), true); + } + return InteractionResultHolder.fail(stack); + } + if (!level.isClientSide() && ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return InteractionResultHolder.fail(stack); + } + + AircraftEntity vehicle = ModEntities.typeFor(def).create(level); + if (vehicle == null) { + return InteractionResultHolder.fail(stack); + } + Vec3 at = blockHit.getLocation(); + vehicle.setType(def); + vehicle.moveTo(at.x, at.y, at.z, player.getYRot(), 0.0F); + if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { + return InteractionResultHolder.fail(stack); + } + + if (!level.isClientSide()) { + level.addFreshEntity(vehicle); + level.gameEvent(player, GameEvent.ENTITY_PLACE, at); + stack.consume(1, player); + } + player.awardStat(Stats.ITEM_USED.get(this)); + return InteractionResultHolder.sidedSuccess(stack, level.isClientSide()); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 0000000..14ca564 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jspecify.annotations.Nullable; + +/** + * A vehicle item as 1.0.0 saved it: one item per vehicle, such as {@code mcheli:ah-64}. + * + *

These are still registered for the vehicles imported at startup, so that stacks in 1.0.0 + * worlds load instead of vanishing. They work as before, are not listed anywhere, and turn into + * the generic item of their kind ({@link AircraftItem#stackOf}) as soon as they sit in a player's + * inventory. Stacks in chests convert once a player takes them out. + */ +public final class LegacyAircraftItem extends AircraftItem { + private final String definitionName; + + public LegacyAircraftItem(String kind, String definitionName, Properties properties) { + super(kind, properties); + this.definitionName = definitionName; + } + + @Override + protected @Nullable String definitionName(ItemStack stack) { + return definitionName; + } + + @Override + public void inventoryTick(ItemStack stack, Level level, Entity owner, int slot, boolean selected) { + if (level.isClientSide() || !(owner instanceof Player player)) { + return; + } + AircraftDefinition def = MCHeli.content().any(definitionName); + if (def == null) { + return; + } + Inventory inventory = player.getInventory(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + if (inventory.getItem(i) == stack) { + inventory.setItem(i, convert(stack, def)); + return; + } + } + } + + /** The generic stack for {@code def}, keeping count, custom name and anything else set on the old one. */ + static ItemStack convert(ItemStack legacy, AircraftDefinition def) { + ItemStack converted = AircraftItem.stackOf(def); + converted.applyComponents(legacy.getComponentsPatch()); + converted.set(ModDataComponents.VEHICLE.get(), def.name()); + converted.setCount(legacy.getCount()); + return converted; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 0000000..364c383 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * Matches one vehicle, such as an AH-6 in the recipe that turns it into an AH-6X. Every vehicle of + * a kind shares an item, so a plain item ingredient would accept any of them. In JSON: + *

{"type": "mcheli:vehicle", "vehicle": "ah-6"}
+ * with the type also under {@code neoforge:ingredient_type} and {@code fabric:type}, since each + * loader names custom ingredients its own way. + * + *

This is the loader-independent half; each loader module wraps it in its own ingredient class. + */ +public final class VehicleIngredient { + public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "vehicle"); + + private VehicleIngredient() { + } + + /** The JSON fields, given a way to build the loader's ingredient from a vehicle name. */ + public static MapCodec codec(Function create, Function vehicle) { + return Codec.STRING.fieldOf("vehicle").xmap(create, vehicle); + } + + public static StreamCodec streamCodec(Function create, Function vehicle) { + return ByteBufCodecs.STRING_UTF8.map(create, vehicle).cast(); + } + + /** The items a vehicle can be: the generic item of each kind. */ + @SuppressWarnings("deprecation") + public static HolderSet kindItems() { + return HolderSet.direct(Item::builtInRegistryHolder, + ModItems.HELICOPTER.get(), ModItems.PLANE.get(), ModItems.TANK.get(), ModItems.VEHICLE.get()); + } + + public static Stream> items() { + return kindItems().stream(); + } + + public static boolean matches(String vehicle, @Nullable ItemStack stack) { + return stack != null && vehicle.equals(AircraftItem.vehicleOf(stack)); + } + + /** What recipe books and JEI show: the vehicle itself, or nothing while it is not loaded. */ + public static Stream displayStacks(String vehicle) { + AircraftDefinition def = MCHeli.content().any(vehicle); + return def != null ? Stream.of(AircraftItem.stackOf(def)) : Stream.empty(); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java new file mode 100644 index 0000000..0f7ccf7 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +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; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.phys.Vec3; + +/** + * Client to server: the player right-clicked a vehicle's hull outside its main hit box. The + * server repeats the reach check against the hull boxes, then treats it as an ordinary click. + * + * @param location where on the hull the click landed, in world coordinates + */ +public record BoardVehiclePacket(int entityId, Vec3 location) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("board_vehicle"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(BoardVehiclePacket::encode, BoardVehiclePacket::decode); + + /** The same slack vanilla allows on top of the player's reach for a click on an entity. */ + private static final double REACH_SLACK = 3.0; + + public static void encode(BoardVehiclePacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeDouble(msg.location.x); + buf.writeDouble(msg.location.y); + buf.writeDouble(msg.location.z); + } + + public static BoardVehiclePacket decode(FriendlyByteBuf buf) { + return new BoardVehiclePacket(buf.readVarInt(), new Vec3(buf.readDouble(), buf.readDouble(), buf.readDouble())); + } + + public static void handle(BoardVehiclePacket msg, ServerPlayer sender) { + if (sender == null || !(sender.level().getEntity(msg.entityId) instanceof AircraftEntity vehicle) || vehicle.isRemoved()) { + return; + } + Vec3 eye = sender.getEyePosition(); + double reach = sender.entityInteractionRange() + REACH_SLACK; + // The click must land on the hull, and the hull must be within reach. + if (eye.distanceToSqr(msg.location) > reach * reach) { + return; + } + Vec3 through = eye.add(msg.location.subtract(eye).scale(1.05)); + boolean onHull = vehicle.clipHitBoxes(eye, through, 0.5).isPresent(); + vehicle.consumeHitBoxFactor(); + if (onHull) { + vehicle.interactAt(sender, msg.location.subtract(vehicle.position()), InteractionHand.MAIN_HAND); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java similarity index 87% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java index 270ac69..8e7136f 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -5,8 +5,10 @@ import com.jnhprojects.mcheli.weapon.Resupply; import com.jnhprojects.mcheli.weapon.VehicleWeapons; 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; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * A one-off request from a crew member: lower the gear, fold the wings, change seats and so on. @@ -15,7 +17,9 @@ * allowed to do it, and then applies the same conditions the original checked before switching. * The result reaches every client through synched data. */ -public record CrewActionPacket(int entityId, Action action) { +public record CrewActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("crew_action"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(CrewActionPacket::encode, CrewActionPacket::decode); public enum Action { /** Raise or lower the landing gear. Pilot only. */ @@ -61,8 +65,7 @@ public static CrewActionPacket decode(FriendlyByteBuf buf) { return new CrewActionPacket(entityId, actions[ordinal]); } - public static void handle(CrewActionPacket msg, CustomPayloadEvent.Context ctx) { - ServerPlayer sender = ctx.getSender(); + public static void handle(CrewActionPacket msg, ServerPlayer sender) { if (sender == null || !(sender.getVehicle() instanceof AircraftEntity vehicle) || vehicle.getId() != msg.entityId) { return; } @@ -131,4 +134,9 @@ public static void handle(CrewActionPacket msg, CustomPayloadEvent.Context ctx) case RESUPPLY -> Resupply.fromInventory(sender, vehicle); } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java similarity index 70% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java index d9ff24a..279b151 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java @@ -3,14 +3,18 @@ import com.jnhprojects.mcheli.entity.ClientAccess; import com.jnhprojects.mcheli.weapon.ExplosionEffect; 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.world.entity.Entity; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * Server to nearby players: draw an MCHeli explosion here. The original's * {@code MCH_PacketEffectExplosion}, plus a flag for the bigger fireball of a crashing vehicle. */ -public record ExplosionEffectPacket(double x, double y, double z, float size, boolean inWater, boolean crash) { +public record ExplosionEffectPacket(double x, double y, double z, float size, boolean inWater, boolean crash) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("explosion_effect"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(ExplosionEffectPacket::encode, ExplosionEffectPacket::decode); public ExplosionEffectPacket(double x, double y, double z, float size, boolean inWater) { this(x, y, z, size, inWater, false); @@ -30,7 +34,7 @@ public static ExplosionEffectPacket decode(FriendlyByteBuf buf) { buf.readBoolean()); } - public static void handle(ExplosionEffectPacket msg, CustomPayloadEvent.Context ctx) { + public static void handle(ExplosionEffectPacket msg) { Entity player = ClientAccess.localPlayer(); if (player == null || !Float.isFinite(msg.size)) { return; @@ -42,4 +46,9 @@ public static void handle(ExplosionEffectPacket msg, CustomPayloadEvent.Context ExplosionEffect.play(player.level(), msg.x, msg.y, msg.z, size, msg.inWater); } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java similarity index 73% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java index 763a492..0056dcc 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java @@ -5,8 +5,10 @@ import com.jnhprojects.mcheli.weapon.Shot; import com.jnhprojects.mcheli.weapon.VehicleWeapons; 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; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * Client to server: the crew member's selected weapon fired on their side, so fire it for real. @@ -15,7 +17,9 @@ * * @param weapon the weapon the client fired, ignored unless it is still the seat's selection */ -public record FireWeaponPacket(int entityId, int weapon, int option1, int option2) { +public record FireWeaponPacket(int entityId, int weapon, int option1, int option2) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("fire_weapon"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(FireWeaponPacket::encode, FireWeaponPacket::decode); public static void encode(FireWeaponPacket msg, FriendlyByteBuf buf) { buf.writeVarInt(msg.entityId); @@ -28,8 +32,7 @@ public static FireWeaponPacket decode(FriendlyByteBuf buf) { return new FireWeaponPacket(buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt()); } - public static void handle(FireWeaponPacket msg, CustomPayloadEvent.Context ctx) { - ServerPlayer sender = ctx.getSender(); + public static void handle(FireWeaponPacket msg, ServerPlayer sender) { if (sender == null || !(sender.getVehicle() instanceof AircraftEntity vehicle) || vehicle.getId() != msg.entityId) { return; } @@ -43,4 +46,9 @@ public static void handle(FireWeaponPacket msg, CustomPayloadEvent.Context ctx) shot.option2 = msg.option2; weapons.use(sender, seat, shot, ServerFiring.INSTANCE); } + + @Override + public Type type() { + return TYPE; + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java similarity index 63% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java index 30d8be7..97d2cbf 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java @@ -2,15 +2,19 @@ import com.jnhprojects.mcheli.entity.AircraftEntity; 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; import net.minecraft.world.entity.Entity; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * Client to server: this player is locking on to an entity. If it is a vehicle with flares, or a * crew member of one, its crew hears the warning. The original's {@code MCH_PacketNotifyLock}. */ -public record LockWarningPacket(int targetId) { +public record LockWarningPacket(int targetId) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("lock_warning"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LockWarningPacket::encode, LockWarningPacket::decode); public static void encode(LockWarningPacket msg, FriendlyByteBuf buf) { buf.writeVarInt(msg.targetId); @@ -20,8 +24,7 @@ public static LockWarningPacket decode(FriendlyByteBuf buf) { return new LockWarningPacket(buf.readVarInt()); } - public static void handle(LockWarningPacket msg, CustomPayloadEvent.Context ctx) { - ServerPlayer sender = ctx.getSender(); + public static void handle(LockWarningPacket msg, ServerPlayer sender) { if (sender == null || !(sender.getVehicle() instanceof AircraftEntity)) { return; } @@ -33,4 +36,9 @@ public static void handle(LockWarningPacket msg, CustomPayloadEvent.Context ctx) } } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java new file mode 100644 index 0000000..de59563 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,81 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Our packets. Each one is a custom payload with its own id and the same encoding on every loader, + * so a client on one loader can talk to a server on another. The loader module registers the + * lists below with its own networking API; handlers always run on the main thread. + */ +public final class ModNetwork { + /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ + public static final int PROTOCOL_VERSION = 6; + + /** Client to server. The handler gets the sending player. */ + public static final List> SERVERBOUND = List.of( + new Serverbound<>(PilotStatePacket.TYPE, PilotStatePacket.STREAM_CODEC, PilotStatePacket::handle), + new Serverbound<>(CrewActionPacket.TYPE, CrewActionPacket.STREAM_CODEC, CrewActionPacket::handle), + 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)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + new Clientbound<>(WeaponStatePacket.TYPE, WeaponStatePacket.STREAM_CODEC, WeaponStatePacket::handle), + 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)); + + private ModNetwork() { + } + + static CustomPacketPayload.Type type(String name) { + return new CustomPacketPayload.Type<>(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, name)); + } + + public static void sendToServer(CustomPacketPayload message) { + Platform.get().sendToServer(message); + } + + public static void sendToPlayer(ServerPlayer player, CustomPacketPayload message) { + Platform.get().sendToPlayer(player, message); + } + + public static void sendToAll(MinecraftServer server, CustomPacketPayload message) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + sendToPlayer(player, message); + } + } + + /** Sends to every player in {@code level} within {@code range} blocks of the point. */ + public static void sendNear(ServerLevel level, double x, double y, double z, double range, CustomPacketPayload message) { + for (ServerPlayer player : level.players()) { + if (player.distanceToSqr(x, y, z) < range * range) { + sendToPlayer(player, message); + } + } + } + + public record Serverbound(CustomPacketPayload.Type type, + StreamCodec codec, + BiConsumer handler) { + } + + public record Clientbound(CustomPacketPayload.Type type, + StreamCodec codec, + Consumer handler) { + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java similarity index 63% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java index dc11b95..89c1644 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java @@ -2,7 +2,9 @@ import com.jnhprojects.mcheli.entity.ClientAccess; import net.minecraft.network.FriendlyByteBuf; -import net.minecraftforge.event.network.CustomPayloadEvent; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; /** * Server to player: one chunk of a pack's zip. Chunks of a pack arrive in order, so @@ -10,7 +12,10 @@ * * @param total length of the whole zip, as in the {@link PackListPacket} */ -public record PackDataPacket(String hash, int offset, int total, byte[] data) { +public record PackDataPacket(String hash, int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_data"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackDataPacket::encode, PackDataPacket::decode); + /** Well under the 1 MiB limit of a clientbound custom payload. */ public static final int CHUNK_SIZE = 128 * 1024; @@ -25,7 +30,12 @@ public static PackDataPacket decode(FriendlyByteBuf buf) { return new PackDataPacket(buf.readUtf(64), buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); } - public static void handle(PackDataPacket msg, CustomPayloadEvent.Context ctx) { + public static void handle(PackDataPacket msg) { ClientAccess.packs().onPackData(msg); } + + @Override + public Type type() { + return TYPE; + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java similarity index 73% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java index b475ead..ad7f0c8 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java @@ -3,7 +3,9 @@ import com.jnhprojects.mcheli.entity.ClientAccess; import io.netty.handler.codec.DecoderException; import net.minecraft.network.FriendlyByteBuf; -import net.minecraftforge.event.network.CustomPayloadEvent; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import java.util.ArrayList; import java.util.List; @@ -14,7 +16,10 @@ * and then rebuilds its content from exactly this list, so both sides fly the same definitions. * The imported 1.0.3 content is never listed: every player imports their own copy. */ -public record PackListPacket(List packs) { +public record PackListPacket(List packs) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_list"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackListPacket::encode, PackListPacket::decode); + public static final int MAX_PACKS = 256; /** @@ -45,7 +50,12 @@ public static PackListPacket decode(FriendlyByteBuf buf) { return new PackListPacket(List.copyOf(packs)); } - public static void handle(PackListPacket msg, CustomPayloadEvent.Context ctx) { + public static void handle(PackListPacket msg) { ClientAccess.packs().onPackList(msg); } + + @Override + public Type type() { + return TYPE; + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java similarity index 63% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java index 8ff7d32..50f10f7 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java @@ -2,8 +2,10 @@ import io.netty.handler.codec.DecoderException; 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; -import net.minecraftforge.event.network.CustomPayloadEvent; import java.util.ArrayList; import java.util.List; @@ -12,7 +14,9 @@ * Player to server: send me these packs, by content hash. The server only answers for hashes in * the list it sent, so a client cannot ask for anything else. */ -public record PackRequestPacket(List hashes) { +public record PackRequestPacket(List hashes) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_request"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackRequestPacket::encode, PackRequestPacket::decode); public static void encode(PackRequestPacket msg, FriendlyByteBuf buf) { buf.writeVarInt(msg.hashes.size()); @@ -33,10 +37,14 @@ public static PackRequestPacket decode(FriendlyByteBuf buf) { return new PackRequestPacket(List.copyOf(hashes)); } - public static void handle(PackRequestPacket msg, CustomPayloadEvent.Context ctx) { - ServerPlayer sender = ctx.getSender(); + public static void handle(PackRequestPacket msg, ServerPlayer sender) { if (sender != null) { PackSync.request(sender, msg.hashes); } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 0000000..2a12ac2 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,156 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import net.minecraft.commands.Commands; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * The server half of the content pack sync. + *

+ *  player joins          ── PackListPacket(id, version, hash, size per pack) ──▶ client
+ *                        ◀── PackRequestPacket(hashes it has not cached) ────────
+ *  every tick            ── PackDataPacket(chunk) ──▶  until each pack is sent
+ * 
+ * Each player gets {@link #CHUNKS_PER_TICK} chunks a tick (about 5 MB a second), one pack after + * the other, so a download never crowds out the game's own packets. A pack's bytes are read when + * its turn comes and dropped once sent. Everything here runs on the server thread. + */ +public final class PackSync { + private static final int CHUNKS_PER_TICK = 2; + + private static final Map> TRANSFERS = new HashMap<>(); + + private PackSync() { + } + + /** Sends a joining player the pack list, and operators any pack problems. */ + public static void onLogin(ServerPlayer player) { + ModNetwork.sendToPlayer(player, list()); + if (seesProblems(player)) { + MCHeli.packs().reportProblems(player::sendSystemMessage); + } + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); + } + + /** The packs the server uses now, as the client should load them. */ + public static PackListPacket list() { + List entries = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + try { + entries.add(new PackListPacket.Entry(pack.id(), pack.manifest().version(), pack.hash(), (int) pack.archiveSize())); + } catch (IOException e) { + MCHeli.LOGGER.error("Content pack {} cannot be sent to players", pack, e); + } + } + return new PackListPacket(List.copyOf(entries)); + } + + /** After a reload: drops transfers in progress and sends every player the new list. */ + public static void sendListToAll(MinecraftServer server) { + TRANSFERS.clear(); + ModNetwork.sendToAll(server, list()); + } + + /** Operators, and the owner of a single player or LAN world, are told what is wrong with the packs. */ + public static boolean seesProblems(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + return server.isSingleplayerOwner(player.getGameProfile()) + || player.createCommandSourceStack().hasPermission(Commands.LEVEL_GAMEMASTERS); + } + + static void request(ServerPlayer player, List hashes) { + Deque queue = TRANSFERS.computeIfAbsent(player.getUUID(), id -> new ArrayDeque<>()); + for (String hash : hashes) { + ContentPack pack = find(hash); + if (pack == null) { + MCHeli.LOGGER.debug("{} asked for content pack {}, which the server does not use", player.getScoreboardName(), hash); + continue; + } + if (queue.stream().noneMatch(t -> t.pack == pack)) { + queue.add(new Transfer(pack)); + } + } + } + + private static @Nullable ContentPack find(String hash) { + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.hash().equals(hash)) { + return pack; + } + } + return null; + } + + public static void pump(MinecraftServer server) { + Iterator>> it = TRANSFERS.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry> entry = it.next(); + ServerPlayer player = server.getPlayerList().getPlayer(entry.getKey()); + Deque queue = entry.getValue(); + if (player == null || queue.isEmpty()) { + it.remove(); + continue; + } + for (int sent = 0; sent < CHUNKS_PER_TICK && !queue.isEmpty(); sent++) { + Transfer transfer = queue.peek(); + PackDataPacket chunk = transfer.next(); + if (chunk == null) { + queue.poll(); + continue; + } + ModNetwork.sendToPlayer(player, chunk); + if (transfer.done()) { + queue.poll(); + } + } + } + } + + /** One pack on its way to one player. */ + private static final class Transfer { + private final ContentPack pack; + private byte @Nullable [] data; + private int offset; + + Transfer(ContentPack pack) { + this.pack = pack; + } + + /** The next chunk, or {@code null} when the pack could not be read. */ + @Nullable PackDataPacket next() { + if (data == null) { + try { + data = pack.archive(); + } catch (IOException e) { + MCHeli.LOGGER.error("Could not read content pack {} to send it", pack, e); + return null; + } + } + int end = Math.min(offset + PackDataPacket.CHUNK_SIZE, data.length); + PackDataPacket chunk = new PackDataPacket(pack.hash(), offset, data.length, Arrays.copyOfRange(data, offset, end)); + offset = end; + return chunk; + } + + boolean done() { + return data != null && offset >= data.length; + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java similarity index 71% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java index 1d71598..6426f06 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java @@ -2,8 +2,10 @@ import com.jnhprojects.mcheli.entity.AircraftEntity; 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; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * Sent by the pilot every tick. Position, yaw and pitch already travel through the vanilla @@ -12,7 +14,9 @@ * @param keys the pilot's key state as packed by {@link AircraftEntity.ControlInput#write()}, so * other players see wheels steer and tracks turn */ -public record PilotStatePacket(int entityId, float throttle, float reverse, float roll, boolean hovering, byte keys) { +public record PilotStatePacket(int entityId, float throttle, float reverse, float roll, boolean hovering, byte keys) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pilot_state"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PilotStatePacket::encode, PilotStatePacket::decode); public static void encode(PilotStatePacket msg, FriendlyByteBuf buf) { buf.writeVarInt(msg.entityId); @@ -27,8 +31,7 @@ public static PilotStatePacket decode(FriendlyByteBuf buf) { return new PilotStatePacket(buf.readVarInt(), buf.readFloat(), buf.readFloat(), buf.readFloat(), buf.readBoolean(), buf.readByte()); } - public static void handle(PilotStatePacket msg, CustomPayloadEvent.Context ctx) { - ServerPlayer sender = ctx.getSender(); + public static void handle(PilotStatePacket msg, ServerPlayer sender) { if (sender == null) { return; } @@ -42,4 +45,9 @@ public static void handle(PilotStatePacket msg, CustomPayloadEvent.Context ctx) aircraft.acceptPilotState(msg.throttle, msg.reverse, msg.roll, msg.hovering, msg.keys); } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java similarity index 67% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java index eb1c191..292f643 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java @@ -4,14 +4,18 @@ import com.jnhprojects.mcheli.entity.ClientAccess; import com.jnhprojects.mcheli.weapon.VehicleWeapons; 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.world.entity.Entity; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * Server to crew: the magazine and reserve of one weapon after it fired, reloaded or was * resupplied. The original's {@code MCH_PacketNotifyAmmoNum}. */ -public record WeaponStatePacket(int entityId, int weapon, int ammo, int rest) { +public record WeaponStatePacket(int entityId, int weapon, int ammo, int rest) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("weapon_state"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(WeaponStatePacket::encode, WeaponStatePacket::decode); public static void encode(WeaponStatePacket msg, FriendlyByteBuf buf) { buf.writeVarInt(msg.entityId); @@ -24,7 +28,7 @@ public static WeaponStatePacket decode(FriendlyByteBuf buf) { return new WeaponStatePacket(buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt()); } - public static void handle(WeaponStatePacket msg, CustomPayloadEvent.Context ctx) { + public static void handle(WeaponStatePacket msg) { Entity player = ClientAccess.localPlayer(); if (player != null && player.level().getEntity(msg.entityId) instanceof AircraftEntity vehicle) { VehicleWeapons weapons = vehicle.weapons(); @@ -33,4 +37,9 @@ public static void handle(WeaponStatePacket msg, CustomPayloadEvent.Context ctx) } } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 0000000..22f7370 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.util.ExtraCodecs; + +/** + * A soft ball of light blended on top of the scene: the flash of an explosion, a burning wreck, an + * ember. Spawned on the client only, but the codecs let {@code /particle mcheli:glow} show it too. + * + * @param color RGB of the light + * @param size radius in blocks at its largest + * @param lifetime ticks until it has faded out + * @param flicker whether it wavers like a fire rather than fading smoothly like a flash + */ +public record GlowParticleOptions(int color, float size, int lifetime, boolean flicker) implements ParticleOptions { + /** White hot at the centre of a blast. */ + public static final int FLASH = 0xFFE2B0; + /** Burning fuel and the fire inside the smoke. */ + public static final int FIRE = 0xFF8A2E; + /** Embers and the deeper red at the edge of a fireball. */ + public static final int EMBER = 0xFF5A1E; + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + Codec.INT.fieldOf("color").forGetter(GlowParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(GlowParticleOptions::size), + ExtraCodecs.POSITIVE_INT.fieldOf("lifetime").forGetter(GlowParticleOptions::lifetime), + Codec.BOOL.optionalFieldOf("flicker", false).forGetter(GlowParticleOptions::flicker)) + .apply(i, GlowParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, GlowParticleOptions::color, + ByteBufCodecs.FLOAT, GlowParticleOptions::size, + ByteBufCodecs.VAR_INT, GlowParticleOptions::lifetime, + ByteBufCodecs.BOOL, GlowParticleOptions::flicker, + GlowParticleOptions::new); + + public static GlowParticleOptions flash(int color, float size, int lifetime) { + return new GlowParticleOptions(color, size, lifetime, false); + } + + public static GlowParticleOptions fire(int color, float size, int lifetime) { + return new GlowParticleOptions(color, size, lifetime, true); + } + + @Override + public ParticleType getType() { + return ModParticles.GLOW.get(); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 0000000..e951f49 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Platform.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * What the shared code needs from the mod loader. Each loader module installs its implementation + * first thing during mod construction, before anything else of ours runs. + * + *

Kept small on purpose: registration and events go the other way, with the loader module + * calling into the shared code (see {@code Registration} and the {@code *Hooks} classes). + */ +public interface Platform { + + static Platform get() { + Platform platform = Holder.instance; + if (platform == null) { + throw new IllegalStateException("No mod loader platform installed yet"); + } + return platform; + } + + static void install(Platform platform) { + if (Holder.instance != null) { + throw new IllegalStateException("Platform already installed: " + Holder.instance.loaderName()); + } + Holder.instance = Objects.requireNonNull(platform); + } + + /** {@code forge}, {@code neoforge} or {@code fabric}, for logs and {@code /mcheli status}. */ + String loaderName(); + + /** The game directory, which holds the {@code mcheli} folder. */ + Path gameDir(); + + /** The config directory, which holds the client config. */ + Path configDir(); + + /** Our version as the loader read it from the mod metadata. */ + String modVersion(); + + /** Whether this is a physical client, as opposed to a dedicated server. */ + boolean isClient(); + + void sendToServer(CustomPacketPayload payload); + + void sendToPlayer(ServerPlayer player, CustomPacketPayload payload); + + /** + * A builder for one of our creative tabs, placed after {@code after}. Vanilla's builder is not + * public on every loader and only Forge and NeoForge can order tabs. + */ + CreativeModeTab.Builder creativeTab(ResourceKey after); + + final class Holder { + private static volatile Platform instance; + + private Holder() { + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 0000000..e90f274 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Registered.java @@ -0,0 +1,35 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.resources.ResourceLocation; + +import java.util.function.Supplier; + +/** + * A registry entry the shared code declares and the loader module registers, like Forge's + * {@code RegistryObject}. {@link #get()} works once the loader has registered it. + */ +public final class Registered implements Supplier { + private final ResourceLocation id; + private volatile T value; + + Registered(ResourceLocation id) { + this.id = id; + } + + public ResourceLocation id() { + return id; + } + + @Override + public T get() { + T v = value; + if (v == null) { + throw new IllegalStateException("Registry entry " + id + " is not registered yet"); + } + return v; + } + + void bind(T value) { + this.value = value; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 0000000..67d899a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/platform/Registration.java @@ -0,0 +1,80 @@ +package com.jnhprojects.mcheli.platform; + +import com.jnhprojects.mcheli.MCHeli; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Every vanilla registry entry the mod adds. The shared registry classes declare their entries + * here during mod construction; the loader module then registers them its own way: Forge and + * NeoForge from their register event, Fabric straight away. + */ +public final class Registration { + private static final List> PENDING = new ArrayList<>(); + private static final Set>> REGISTRIES = new LinkedHashSet<>(); + + private Registration() { + } + + /** + * Declares an entry. + * + * @param factory makes the value; it gets the entry's key for {@code Item.Properties#setId} + * and the like + */ + public static synchronized Registered register(ResourceKey> registry, String name, + Function, T> factory) { + ResourceLocation id = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, name); + Registered entry = new Registered<>(id); + PENDING.add(new Pending<>(registry, entry, factory)); + REGISTRIES.add(registry); + return entry; + } + + /** The registries with entries of ours, in the order they were first used. */ + public static synchronized List>> registries() { + return List.copyOf(REGISTRIES); + } + + /** + * A key from {@link #registries()} with its element type filled in, for the loader's generic + * register calls. The type is whatever the caller needs; entries are only ever handed to the + * registry they were declared for. + */ + @SuppressWarnings("unchecked") + public static ResourceKey> typed(ResourceKey> registry) { + return (ResourceKey>) registry; + } + + /** + * Creates our entries for one registry, in declaration order, and hands each to the loader. + * Called once per registry. + */ + public static synchronized void registerAll(ResourceKey> registry, BiConsumer sink) { + for (Pending pending : PENDING) { + if (pending.registry.equals(registry)) { + @SuppressWarnings("unchecked") + Pending ours = (Pending) pending; + ours.create(sink); + } + } + } + + private record Pending(ResourceKey> registry, Registered entry, + Function, T> factory) { + @SuppressWarnings("unchecked") + void create(BiConsumer sink) { + T value = factory.apply(ResourceKey.create((ResourceKey>) registry, entry.id())); + entry.bind(value); + sink.accept(entry.id(), value); + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java similarity index 60% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java index fe1dc6f..8b24af0 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -3,15 +3,15 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceKey; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.CreativeModeTabs; import net.minecraft.world.item.ItemStack; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.RegistryObject; import java.util.List; @@ -20,21 +20,22 @@ * read from the content registry whenever a tab is built, so reloaded content shows up. */ public final class ModCreativeTabs { - private static final DeferredRegister TABS = DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MCHeli.MOD_ID); - - public static final RegistryObject HELICOPTERS = register("helicopters", "helicopter", "ah-64", + public static final Registered HELICOPTERS = register("helicopters", "helicopter", "ah-64", CreativeModeTabs.SPAWN_EGGS); - public static final RegistryObject PLANES = register("planes", "plane", "f-35b", HELICOPTERS.getKey()); - public static final RegistryObject TANKS = register("tanks", "tank", "m1a2", PLANES.getKey()); - public static final RegistryObject VEHICLES = register("vehicles", "vehicle", "mk15", TANKS.getKey()); + public static final Registered PLANES = register("planes", "plane", "f-35b", key(HELICOPTERS)); + public static final Registered TANKS = register("tanks", "tank", "m1a2", key(PLANES)); + public static final Registered VEHICLES = register("vehicles", "vehicle", "mk15", key(TANKS)); private ModCreativeTabs() { } - private static RegistryObject register(String name, String kind, String icon, ResourceKey after) { - return TABS.register(name, () -> CreativeModeTab.builder() + private static ResourceKey key(Registered tab) { + return ResourceKey.create(Registries.CREATIVE_MODE_TAB, tab.id()); + } + + private static Registered register(String name, String kind, String icon, ResourceKey after) { + return Registration.register(Registries.CREATIVE_MODE_TAB, name, key -> Platform.get().creativeTab(after) .title(Component.translatable("itemGroup.mcheli." + name)) - .withTabsBefore(after) .icon(() -> { AircraftDefinition def = MCHeli.content().any(icon); return def != null ? AircraftItem.stackOf(def) : new ItemStack(ModItems.forKind(kind)); @@ -54,7 +55,7 @@ public static List all() { return List.of(HELICOPTERS.get(), PLANES.get(), TANKS.get(), VEHICLES.get()); } - public static void register(BusGroup modBus) { - TABS.register(modBus); + /** Declares the entries; the loader module registers them. */ + public static void init() { } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java similarity index 51% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java index 2cb9b7f..08b564e 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -1,24 +1,19 @@ package com.jnhprojects.mcheli.registry; -import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; import com.mojang.serialization.Codec; import net.minecraft.core.component.DataComponentType; import net.minecraft.core.registries.Registries; import net.minecraft.network.codec.ByteBufCodecs; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.RegistryObject; public final class ModDataComponents { - private static final DeferredRegister> COMPONENTS = - DeferredRegister.create(Registries.DATA_COMPONENT_TYPE, MCHeli.MOD_ID); - /** * The definition a vehicle item places, such as {@code ah-64}. Saved with the stack and sent * to clients; the item itself only says which kind of vehicle it is. */ - public static final RegistryObject> VEHICLE = COMPONENTS.register("vehicle", - () -> DataComponentType.builder() + public static final Registered> VEHICLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "vehicle", + key -> DataComponentType.builder() .persistent(Codec.STRING) .networkSynchronized(ByteBufCodecs.STRING_UTF8) .build()); @@ -26,7 +21,7 @@ public final class ModDataComponents { private ModDataComponents() { } - public static void register(BusGroup modBus) { - COMPONENTS.register(modBus); + /** Declares the entries; the loader module registers them. */ + public static void init() { } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 0000000..19441e8 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,108 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.BombEntity; +import com.jnhprojects.mcheli.entity.projectile.BulletEntity; +import com.jnhprojects.mcheli.entity.projectile.DispensedItemEntity; +import com.jnhprojects.mcheli.entity.projectile.FlareEntity; +import com.jnhprojects.mcheli.entity.projectile.MarkerRocketEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.entity.projectile.RocketEntity; +import com.jnhprojects.mcheli.entity.projectile.TorpedoEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; + +/** + * One entity type per vehicle kind. The concrete vehicle is stored on the entity, the same approach + * the original took, so adding content never changes the entity registry. + */ +public final class ModEntities { + public static final Registered> HELICOPTER = register("helicopter", HelicopterEntity::new); + public static final Registered> PLANE = register("plane", PlaneEntity::new); + public static final Registered> TANK = register("tank", TankEntity::new); + public static final Registered> VEHICLE = register("vehicle", GroundVehicleEntity::new); + + public static final Registered> BULLET = projectile("bullet", BulletEntity::new); + public static final Registered> ROCKET = projectile("rocket", RocketEntity::new); + public static final Registered> BOMB = projectile("bomb", BombEntity::new); + public static final Registered> AA_MISSILE = projectile("aa_missile", AAMissileEntity::new); + public static final Registered> AS_MISSILE = projectile("as_missile", ASMissileEntity::new); + public static final Registered> AT_MISSILE = projectile("at_missile", ATMissileEntity::new); + public static final Registered> TV_MISSILE = projectile("tv_missile", TvMissileEntity::new); + public static final Registered> TORPEDO = projectile("torpedo", TorpedoEntity::new); + public static final Registered> MARKER_ROCKET = projectile("marker_rocket", MarkerRocketEntity::new); + public static final Registered> DISPENSED_ITEM = projectile("dispensed_item", DispensedItemEntity::new); + public static final Registered> FLARE = Registration.register(Registries.ENTITY_TYPE, "flare", key -> EntityType.Builder + .of(FlareEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(16) + .updateInterval(2) + .fireImmune() + .noSave() + .build(key.location().toString())); + public static final Registered> A10 = Registration.register(Registries.ENTITY_TYPE, "a10", key -> EntityType.Builder + .of(A10Entity::new, MobCategory.MISC) + .sized(5.0F, 3.0F) + .clientTrackingRange(32) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key.location().toString())); + + private ModEntities() { + } + + /** Projectiles are a metre cube like the original's, and never saved. */ + private static Registered> projectile(String name, EntityType.EntityFactory factory) { + return Registration.register(Registries.ENTITY_TYPE, name, key -> EntityType.Builder.of(factory, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(16) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key.location().toString())); + } + + private static Registered> register(String name, EntityType.EntityFactory factory) { + return Registration.register(Registries.ENTITY_TYPE, name, key -> EntityType.Builder.of(factory, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(16) + .updateInterval(1) + .fireImmune() + .build(key.location().toString())); + } + + /** The entity type that hosts a definition of this kind. */ + public static EntityType typeFor(AircraftDefinition def) { + if (def instanceof HelicopterDefinition) { + return HELICOPTER.get(); + } + if (def instanceof PlaneDefinition) { + return PLANE.get(); + } + if (def instanceof TankDefinition) { + return TANK.get(); + } + return VEHICLE.get(); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java similarity index 55% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java index 73db594..7eec878 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -1,16 +1,14 @@ package com.jnhprojects.mcheli.registry; -import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.item.AircraftItem; import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; import net.minecraft.world.item.Item; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.ForgeRegistries; -import net.minecraftforge.registries.RegistryObject; import java.util.Map; @@ -19,24 +17,26 @@ * items, one per vehicle, are kept for the content present at startup so old worlds keep them. */ public final class ModItems { - private static final DeferredRegister ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, MCHeli.MOD_ID); + public static final Registered HELICOPTER = kind("helicopter"); + public static final Registered PLANE = kind("plane"); + public static final Registered TANK = kind("tank"); + public static final Registered VEHICLE = kind("vehicle"); - public static final RegistryObject HELICOPTER = kind("helicopter"); - public static final RegistryObject PLANE = kind("plane"); - public static final RegistryObject TANK = kind("tank"); - public static final RegistryObject VEHICLE = kind("vehicle"); - - private static final Map> BY_KIND = + private static final Map> BY_KIND = Map.of("helicopter", HELICOPTER, "plane", PLANE, "tank", TANK, "vehicle", VEHICLE); private ModItems() { } - private static RegistryObject kind(String kind) { - return ITEMS.register(kind, () -> new AircraftItem(kind, new Item.Properties().setId(ITEMS.key(kind)).stacksTo(1))); + private static Registered kind(String kind) { + return Registration.register(Registries.ITEM, kind, key -> new AircraftItem(kind, new Item.Properties().stacksTo(1))); } - public static void register(BusGroup modBus, ContentRegistry content) { + /** + * Declares the entries, with a 1.0.0 item for each original vehicle in {@code content}; the + * loader module registers them. + */ + public static void init(ContentRegistry content) { for (AircraftDefinition def : content.all()) { String name = def.name(); // Only the imported content existed in 1.0.0; pack vehicles never had their own item. @@ -45,15 +45,14 @@ public static void register(BusGroup modBus, ContentRegistry content) { continue; } String kind = def.kind(); - ITEMS.register(name, () -> new LegacyAircraftItem(kind, name, - new Item.Properties().setId(ITEMS.key(name)).stacksTo(1))); + Registration.register(Registries.ITEM, name, key -> new LegacyAircraftItem(kind, name, + new Item.Properties().stacksTo(1))); } - ITEMS.register(modBus); } /** The generic item of a vehicle kind ({@code helicopter}, {@code plane}, {@code tank} or {@code vehicle}). */ public static Item forKind(String kind) { - RegistryObject item = BY_KIND.get(kind); + Registered item = BY_KIND.get(kind); if (item == null) { throw new IllegalArgumentException("Unknown vehicle kind " + kind); } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java similarity index 60% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java index 956a36b..ce9a1da 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -1,22 +1,18 @@ package com.jnhprojects.mcheli.registry; -import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; import com.mojang.serialization.MapCodec; import net.minecraft.core.particles.ParticleType; import net.minecraft.core.registries.Registries; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.RegistryObject; public final class ModParticles { - private static final DeferredRegister> PARTICLE_TYPES = DeferredRegister.create(Registries.PARTICLE_TYPE, MCHeli.MOD_ID); - /** Ignores the particle limit: an explosion that loses its flash to the cap looks broken. */ - public static final RegistryObject> GLOW = PARTICLE_TYPES.register("glow", - () -> new ParticleType<>(true) { + public static final Registered> GLOW = Registration.register(Registries.PARTICLE_TYPE, "glow", + key -> new ParticleType<>(true) { @Override public MapCodec codec() { return GlowParticleOptions.CODEC; @@ -31,7 +27,7 @@ public StreamCodec streamC private ModParticles() { } - public static void register(BusGroup modBus) { - PARTICLE_TYPES.register(modBus); + /** Declares the entries; the loader module registers them. */ + public static void init() { } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java new file mode 100644 index 0000000..ca7a9c4 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.tags.TagKey; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.jspecify.annotations.Nullable; + +/** Damage rules shared by bullets and explosions. */ +public final class Combat { + private Combat() { + } + + /** The {@code DamageFactor} kind a target belongs to, or {@code null} for anything else. */ + public static @Nullable String kind(Entity entity) { + return switch (entity) { + case Player p -> "player"; + case HelicopterEntity h -> "helicopter"; + case PlaneEntity p -> "plane"; + case TankEntity t -> "tank"; + case GroundVehicleEntity v -> "vehicle"; + default -> null; + }; + } + + public static float factor(@Nullable WeaponDefinition def, Entity target) { + String kind = kind(target); + return def != null && kind != null ? def.damageFactor(kind) : 1.0F; + } + + /** + * Deals weapon damage. The original scaled every living target's invulnerability window by + * {@code HurtResistantTime}, zero by default, so each round of a burst lands. + */ + public static boolean hurt(Entity target, DamageSource source, float damage, @Nullable WeaponDefinition def) { + if (!(target.level() instanceof ServerLevel)) { + return false; + } + if (target instanceof LivingEntity living) { + living.invulnerableTime = 0; + } + return target.hurt(source, damage * factor(def, target)); + } + + /** The convention tag every loader fills, so modded panes count too. */ + private static final TagKey GLASS_PANES = TagKey.create(Registries.BLOCK, ResourceLocation.fromNamespaceAndPath("c", "glass_panes")); + + /** + * {@code BulletBreakableBlocks} with its default list: panes, grass, flowers, vines, wheat, + * sugar cane and lily pads, which a round shoots through. + */ + public static boolean isBulletBreakable(BlockState state) { + return state.is(GLASS_PANES) + || state.is(BlockTags.FLOWERS) + || state.is(Blocks.SHORT_GRASS) || state.is(Blocks.TALL_GRASS) + || state.is(Blocks.FERN) || state.is(Blocks.LARGE_FERN) + || state.is(Blocks.VINE) || state.is(Blocks.WHEAT) + || state.is(Blocks.SUGAR_CANE) || state.is(Blocks.LILY_PAD); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java new file mode 100644 index 0000000..0ac55ea --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -0,0 +1,215 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.projectile.FlareEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.network.ExplosionEffectPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.ExperienceOrb; +import net.minecraft.world.entity.item.FallingBlockEntity; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Explosion; +import net.minecraft.world.level.ExplosionDamageCalculator; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +/** + * Port of {@code MCH_Explosion}. + * + *

The original explosion had two sizes: one for blocks and one, doubled, for entities, with its + * own damage curve and the weapon's per-target {@code DamageFactor}. Entity damage is computed + * here as it was; the blocks, particles and sound go through a vanilla explosion of the block size, + * so mob griefing rules, protection mods and the client effects all behave as players expect. + */ +public final class Explosions { + /** Players this far away still see the smoke of an explosion. */ + private static final double EFFECT_RANGE = 256.0; + /** Share of the blast knockback an empty vehicle takes; they are far heavier than a mob. */ + private static final double VEHICLE_PUSH = 0.25; + + private Explosions() { + } + + /** What an explosion request needs besides its position and size. */ + public record Params(@Nullable Entity exploder, @Nullable Entity cause, float size, float blockSize, + boolean flaming, boolean destroyBlocks, int fireSeconds, boolean inWater, + @Nullable WeaponDefinition weapon) { + } + + /** + * @return whether any entity took damage, which the shooter hears as a hit confirmation + */ + public static boolean explode(ServerLevel level, double x, double y, double z, Params p) { + Vec3 center = new Vec3(x, y, z); + boolean hit = damageEntities(level, center, p); + + boolean breakBlocks = p.destroyBlocks && p.blockSize > 0.0F && ServerConfig.explosionsBreakBlocks(); + float radius = breakBlocks ? p.blockSize : Math.max(p.size, 0.5F); + level.explode(p.exploder, explosionSource(level, p), new Calculator(p.inWater, level), + x, y, z, radius, p.flaming && breakBlocks && ServerConfig.explosionsStartFires(), + breakBlocks ? Level.ExplosionInteraction.MOB : Level.ExplosionInteraction.NONE); + ModNetwork.sendNear(level, x, y, z, EFFECT_RANGE, new ExplosionEffectPacket(x, y, z, p.size, p.inWater)); + if (!p.inWater) { + throwDebris(level, x, y, z, p.size); + } + return hit; + } + + /** + * The look of a vehicle going up when it crashes: no damage or blocks of its own (the crash + * explosion does those), but the {@link ExplosionEffect#crash big fireball} on every nearby + * client and twice the burning debris. Drawn a little above the impact so its smoke spreads + * into the air the way a missile hit's does, rather than straight into the ground. + */ + public static void crashFireball(ServerLevel level, double x, double y, double z, float size) { + float visual = Math.clamp(size * 2.0F, 6.0F, ExplosionEffect.MAX_CRASH_SIZE); + double lift = visual * 0.25; + ModNetwork.sendNear(level, x, y + lift, z, EFFECT_RANGE, new ExplosionEffectPacket(x, y + lift, z, visual, false, true)); + throwDebris(level, x, y, z, visual, 24); + } + + private static void throwDebris(ServerLevel level, double x, double y, double z, float size) { + throwDebris(level, x, y, z, size, 12); + } + + /** + * Big blasts throw burning debris that arcs away trailing smoke, which the original made of + * flares. One piece per point of size, up to {@code maxPieces}. + */ + private static void throwDebris(ServerLevel level, double x, double y, double z, float size, int maxPieces) { + if (size < 4.0F) { + return; + } + int pieces = Math.min((int) size * maxPieces / 12, maxPieces); + for (int i = 0; i < pieces; i++) { + double angle = Math.toRadians(level.getRandom().nextInt(360)); + double speed = Math.min(size / 12.0F, 0.6) * (0.5F + level.getRandom().nextFloat() * 0.5F); + FlareEntity debris = ModEntities.FLARE.get().create(level); + if (debris != null) { + double spread = size / 2.0; + debris.launch(x + (level.getRandom().nextDouble() - 0.5) * spread, y + 2.0, z + (level.getRandom().nextDouble() - 0.5) * spread, + Math.sin(angle) * speed, (1.0 + level.getRandom().nextFloat() * 0.2) * speed, Math.cos(angle) * speed, 2.0F, 0, false); + level.addFreshEntity(debris); + } + } + } + + private static DamageSource explosionSource(ServerLevel level, Params p) { + return level.damageSources().explosion(p.exploder, p.cause); + } + + /** {@code doExplosionA} for entities: twice the block size, the 1.7.10 damage curve and knockback. */ + private static boolean damageEntities(ServerLevel level, Vec3 center, Params p) { + float size = p.size * 2.0F; + if (size <= 0.0F) { + return false; + } + AABB area = new AABB(center, center).inflate(size + 1.0); + DamageSource source = explosionSource(level, p); + boolean hit = false; + for (Entity entity : level.getEntities(p.exploder, area)) { + double d7 = Math.sqrt(entity.distanceToSqr(center)) / size; + if (d7 > 1.0) { + continue; + } + double dx = entity.getX() - center.x; + double dy = entity.getEyeY() - center.y; + double dz = entity.getZ() - center.z; + double dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (dist == 0.0) { + continue; + } + dx /= dist; + dy /= dist; + dz /= dist; + double exposure = Explosion.getSeenPercent(center, entity); + double impact = (1.0 - d7) * exposure; + float damage = (int) ((impact * impact + impact) / 2.0 * 8.0 * size + 1.0); + if (damage > 0.0F && !(entity instanceof ItemEntity) && !(entity instanceof ExperienceOrb) + && !(entity instanceof FallingBlockEntity)) { + // Our own rounds caught in the blast do not count as hitting something. + if (!(entity instanceof ProjectileEntity bullet) || bullet.user() != p.cause) { + hit = true; + } + } + Combat.hurt(entity, source, damage, p.weapon); + if (entity instanceof AircraftEntity vehicle) { + // Not in the original: a vehicle someone is driving is simulated on their client, + // so a push here only snaps it back a step. An empty one still rocks a little. + if (vehicle.getControllingPassenger() == null) { + entity.push(dx * impact * VEHICLE_PUSH * 0.4, dy * impact * VEHICLE_PUSH * 0.1, dz * impact * VEHICLE_PUSH * 0.4); + } + } else if (!(entity instanceof ProjectileEntity)) { + entity.push(dx * impact * 0.4, dy * impact * 0.1, dz * impact * 0.4); + } + if (damage > 0.0F && p.fireSeconds > 0) { + double fire = 1.0 - dist / size; + if (fire > 0.0) { + entity.igniteForSeconds((float) (fire * p.fireSeconds)); + } + } + } + return hit; + } + + /** + * Vanilla explosion settings for the block pass: no entity damage or knockback (done above), + * and under water the blocks crumble the way the original's water charges made them. + */ + private static final class Calculator extends ExplosionDamageCalculator { + private final boolean inWater; + private final Level level; + + Calculator(boolean inWater, Level level) { + this.inWater = inWater; + this.level = level; + } + + // Vanilla's own calculator reads the block's resistance the same deprecated way. + @SuppressWarnings("deprecation") + @Override + public Optional getBlockExplosionResistance(Explosion explosion, BlockGetter getter, BlockPos pos, + BlockState block, FluidState fluid) { + if (!inWater) { + return super.getBlockExplosionResistance(explosion, getter, pos, block, fluid); + } + if (block.isAir()) { + return Optional.empty(); + } + float resistance = block.getBlock().getExplosionResistance(); + return Optional.of(resistance * (level.getRandom().nextFloat() * 0.2F + 0.2F)); + } + + @Override + public boolean shouldDamageEntity(Explosion explosion, Entity entity) { + return false; + } + + @Override + public float getKnockbackMultiplier(Entity entity) { + return 0.0F; + } + } + + /** {@code playExplosionSound}: the bang of a round that has no explosive charge. */ + public static void playSound(Level level, double x, double y, double z) { + float pitch = (1.0F + (level.getRandom().nextFloat() - level.getRandom().nextFloat()) * 0.2F) * 0.7F; + level.playSound(null, x, y, z, SoundEvents.GENERIC_EXPLODE.value(), SoundSource.BLOCKS, 4.0F, pitch); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java new file mode 100644 index 0000000..ff583c8 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java @@ -0,0 +1,194 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.projectile.FlareEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.LevelEvent; +import net.minecraft.world.phys.Vec3; + +/** + * Flares and smoke dischargers, ported from {@code MCH_Flare} and the flare half of + * {@code MCH_MissileDetector}. While a salvo burns, every missile homing on the vehicle within 60 + * blocks is decoyed. + * + *

The ten {@code FlareType} values pick a pattern: 1 random scatter, 2 and 3 to the sides and + * rear, 4 and 5 trailing, 10 a ring of smoke grenades. Unknown types fall back to type 1. + */ +public final class FlareDispenser { + private static final int DECOY_RANGE = 60; + + /** {@code num, interval, tickWait, tickEnable, numFlareMax} per type. */ + private static final int[][] TYPES = new int[11][]; + + static { + TYPES[1] = new int[]{1, 3, 200, 100, 16}; + TYPES[2] = new int[]{3, 5, 300, 200, 16}; + TYPES[3] = new int[]{2, 3, 200, 100, 16}; + TYPES[4] = new int[]{1, 3, 200, 100, 16}; + TYPES[5] = new int[]{2, 3, 200, 100, 16}; + TYPES[10] = new int[]{8, 1, 250, 60, 1}; + for (int i : new int[]{0, 6, 7, 8, 9}) { + TYPES[i] = TYPES[1]; + } + } + + private final AircraftEntity vehicle; + private final AircraftDefinition def; + private final RandomSource random = RandomSource.create(); + private int tick; + private int fired; + private int type; + private int typeIndex; + + FlareDispenser(AircraftEntity vehicle, AircraftDefinition def) { + this.vehicle = vehicle; + this.def = def; + } + + public boolean isFitted() { + return !def.flareTypes.isEmpty(); + } + + /** Waiting out the reload of the last salvo. */ + public boolean isPreparing() { + return tick != 0; + } + + /** The salvo is still burning, so incoming missiles are being decoyed. */ + public boolean isActive() { + int[] p = params(type); + return tick != 0 && tick > p[2] - p[3]; + } + + public int ticksLeft() { + return tick; + } + + public int cooldown() { + return params(type)[2]; + } + + public int currentType() { + return def.flareTypes.isEmpty() ? 0 : def.flareTypes.get(typeIndex % def.flareTypes.size()); + } + + public void nextType() { + if (!def.flareTypes.isEmpty()) { + typeIndex = (typeIndex + 1) % def.flareTypes.size(); + } + } + + /** {@code useFlare}: starts a salvo of the current type if the last one has finished. */ + public boolean use() { + if (!isFitted() || tick != 0 || vehicle.isDestroyed()) { + return false; + } + type = currentType(); + fired = 0; + // The client waited 50 ticks longer before showing the dispenser as ready again. + tick = params(type)[2] + (vehicle.level().isClientSide() ? 50 : 0); + return true; + } + + private static int[] params(int type) { + return type >= 0 && type < TYPES.length && TYPES[type] != null ? TYPES[type] : TYPES[1]; + } + + void tick() { + if (type <= 0 || vehicle.isRemoved()) { + return; + } + if (tick > 0) { + tick--; + } + int[] p = params(type); + if (!vehicle.level().isClientSide()) { + if (tick > 0 && tick % p[1] == 0 && fired < p[4]) { + Vec3 at = vehicle.toWorld(def.flarePosition); + spawnSalvo(new Vec3(at.x + vehicle.xo - vehicle.getX(), at.y + vehicle.yo - vehicle.getY(), at.z + vehicle.zo - vehicle.getZ())); + } + if (isActive()) { + decoyMissiles(); + } + } + } + + private void spawnSalvo(Vec3 at) { + fired++; + int[] p = params(type); + Vec3 m = vehicle.getDeltaMovement(); + double x = at.x - m.x * 2.0; + double y = at.y - m.y * 2.0 - 1.0; + double z = at.z - m.z * 2.0; + vehicle.level().levelEvent(LevelEvent.SOUND_DISPENSER_PROJECTILE_LAUNCH, net.minecraft.core.BlockPos.containing(x, y, z), 0); + for (int i = 0; i < p[0]; i++) { + double tx = 0.0; + double ty = m.y; + double tz = 0.0; + int fuse = 0; + double r = vehicle.getYRot(); + double fy = y; + switch (type) { + case 1 -> { + tx = Mth.sin(random.nextFloat() * 360.0F); + tz = Mth.cos(random.nextFloat() * 360.0F); + } + case 2, 3 -> { + if (i == 0) { + r += 90.0; + } else if (i == 1) { + r -= 90.0; + } else if (i == 2) { + r += 180.0; + } + r = Math.toRadians(r); + tx = -Math.sin(r) + (random.nextFloat() - 0.5) * 0.6; + tz = Math.cos(r) + (random.nextFloat() - 0.5) * 0.6; + } + case 4 -> { + r = Math.toRadians(r); + tx = -Math.sin(r) + (random.nextFloat() - 0.5) * 1.3; + tz = Math.cos(r) + (random.nextFloat() - 0.5) * 1.3; + } + case 5 -> { + r = Math.toRadians(r); + tx = (-Math.sin(r) + (random.nextFloat() - 0.5) * 0.9) * 0.3; + tz = (Math.cos(r) + (random.nextFloat() - 0.5) * 0.9) * 0.3; + } + default -> { + } + } + tx += m.x; + ty += m.y / 2.0; + tz += m.z; + if (type == 10) { + r += 360.0 / p[0] / 2.0 + i * (360.0 / p[0]); + r = Math.toRadians(r); + tx = -Math.sin(r) * 2.0; + tz = Math.cos(r) * 2.0; + ty = 0.7; + fy += 2.0; + fuse = 10; + } + FlareEntity flare = ModEntities.FLARE.get().create(vehicle.level()); + if (flare != null) { + flare.launch(x, fy, z, tx * 0.5, ty * 0.5, tz * 0.5, 6.0F, fuse, type == 4); + vehicle.level().addFreshEntity(flare); + } + } + } + + /** {@code destroyMissile}: missiles homing on this vehicle or its crew lose their target. */ + private void decoyMissiles() { + for (ProjectileEntity missile : vehicle.level().getEntitiesOfClass(ProjectileEntity.class, + vehicle.getBoundingBox().inflate(DECOY_RANGE), p -> p.target() != null)) { + if (missile.target() == vehicle || vehicle.hasPassenger(missile.target())) { + missile.decoy(); + } + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java new file mode 100644 index 0000000..fe7b760 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.weapon; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Items; +import org.jspecify.annotations.Nullable; + +/** + * Maps the 1.7.10 item names and damage values in the content files to today's items. Names + * that still exist resolve directly; the few that were renamed or flattened are listed here. + */ +public final class LegacyItems { + private static final String[] DYES = { + "ink_sac", "red_dye", "green_dye", "cocoa_beans", "lapis_lazuli", "purple_dye", "cyan_dye", "light_gray_dye", + "gray_dye", "pink_dye", "lime_dye", "yellow_dye", "light_blue_dye", "magenta_dye", "orange_dye", "bone_meal"}; + + private LegacyItems() { + } + + public static @Nullable Item item(String name, int damage) { + if (name.isEmpty()) { + return null; + } + String id = switch (name) { + case "dye" -> DYES[Math.floorMod(damage, DYES.length)]; + case "boat" -> "oak_boat"; + case "reeds" -> "sugar_cane"; + case "wool" -> "white_wool"; + case "log" -> "oak_log"; + case "planks" -> "oak_planks"; + default -> name.startsWith("minecraft:") ? name.substring("minecraft:".length()) : name; + }; + ResourceLocation key = ResourceLocation.tryParse(id.contains(":") ? id : "minecraft:" + id); + if (key == null) { + return null; + } + Item item = BuiltInRegistries.ITEM.get(key); + return item == Items.AIR ? null : item; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java new file mode 100644 index 0000000..73d53c7 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java @@ -0,0 +1,28 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.MCHeli; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageType; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import org.jspecify.annotations.Nullable; + +/** + * Damage types defined in {@code data/mcheli/damage_type}. Bullets and missiles use their own type + * so death messages name the gun instead of calling it a thrown snowball, which is what the + * original's {@code causeThrownDamage} amounted to. + */ +public final class ModDamageTypes { + public static final ResourceKey BULLET = + ResourceKey.create(Registries.DAMAGE_TYPE, ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "bullet")); + + private ModDamageTypes() { + } + + public static DamageSource bullet(Level level, Entity projectile, @Nullable Entity owner) { + return new DamageSource(level.registryAccess().lookupOrThrow(Registries.DAMAGE_TYPE).getOrThrow(BULLET), projectile, owner); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java new file mode 100644 index 0000000..5a536ba --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -0,0 +1,456 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.BombEntity; +import com.jnhprojects.mcheli.entity.projectile.BulletEntity; +import com.jnhprojects.mcheli.entity.projectile.DispensedItemEntity; +import com.jnhprojects.mcheli.entity.projectile.MarkerRocketEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.entity.projectile.RocketEntity; +import com.jnhprojects.mcheli.entity.projectile.TorpedoEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.monster.Enemy; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * What each weapon type does when the server fires it: the {@code shot} methods of the + * {@code MCH_Weapon*} classes. Every method returns whether a round was used. + */ +public final class ServerFiring implements WeaponSet.Trigger { + public static final ServerFiring INSTANCE = new ServerFiring(); + private static final RandomSource RANDOM = RandomSource.create(); + + private ServerFiring() { + } + + @Override + public boolean fire(WeaponSet set, int mount, Shot shot) { + if (!(shot.shooter.level() instanceof ServerLevel level)) { + return false; + } + WeaponDefinition w = set.definition(); + return switch (w.type) { + case MACHINE_GUN_1, MACHINE_GUN_2 -> gun(level, set, shot); + case ROCKET -> rocket(level, set, shot); + case BOMB -> bomb(level, set, shot); + case AA_MISSILE -> aaMissile(level, set, mount, shot); + case AS_MISSILE -> asMissile(level, set, shot); + case AT_MISSILE -> atMissile(level, set, mount, shot); + case TV_MISSILE -> tvMissile(level, set, mount, shot); + case TORPEDO -> torpedo(level, set, shot); + case CAS -> cas(level, set, shot); + case MARKER_ROCKET -> markerRocket(level, set, shot); + case DISPENSER -> dispenser(level, set, shot); + case TARGETING_POD -> targetingPod(level, set, shot); + case DUMMY, SMOKE -> false; + case null -> false; + }; + } + + private static @Nullable T spawn(ServerLevel level, EntityType type, WeaponSet set, Shot shot, + Vec3 dir, float yaw, float pitch, double acceleration) { + T e = type.create(level); + if (e == null) { + return null; + } + e.launch(shot.x, shot.y, shot.z, dir.x, dir.y, dir.z, yaw, pitch, acceleration); + e.setWeapon(set.name()); + e.setParameters(set.definition(), shot.shooter, shot.user); + return e; + } + + private static Vec3 forward(float yaw, float pitch) { + return Vec3.directionFromRotation(pitch, yaw); + } + + private static void sound(WeaponSet set, Entity at) { + sound(set, at, 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); + } + } + + // Guns, rockets, bombs + + private static boolean gun(ServerLevel level, WeaponSet set, Shot shot) { + WeaponDefinition w = set.definition(); + BulletEntity e = spawn(level, ModEntities.BULLET.get(), set, shot, forward(shot.yaw, shot.pitch), shot.yaw, shot.pitch, w.acceleration); + if (e == null) { + return false; + } + if (w.type == WeaponType.MACHINE_GUN_2 && w.modeNum >= 2) { + // Mode 0 fires ball ammunition, which only bangs; mode 1 is high explosive. + e.explosionPower = shot.option1 == 0 ? -w.explosion : w.explosion; + } + Vec3 m = e.getDeltaMovement(); + e.setPos(e.getX() + m.x * 0.5, e.getY() + m.y * 0.5, e.getZ() + m.z * 0.5); + level.addFreshEntity(e); + sound(set, shot.shooter); + return true; + } + + private static boolean rocket(ServerLevel level, WeaponSet set, Shot shot) { + sound(set, shot.shooter); + RocketEntity e = spawn(level, ModEntities.ROCKET.get(), set, shot, forward(shot.yaw, shot.pitch), shot.yaw, shot.pitch, + set.definition().acceleration); + if (e == null) { + return false; + } + if (shot.option1 == 0 && set.definition().modeNum > 1) { + e.piercing = 0; + } + level.addFreshEntity(e); + return true; + } + + private static boolean bomb(ServerLevel level, WeaponSet set, Shot shot) { + WeaponDefinition w = set.definition(); + if (w.destruct) { + // A drone's self destruct charge. + if (shot.shooter instanceof HelicopterEntity drone && drone.definition() != null && drone.definition().uav) { + Explosions.explode(level, drone.getX(), drone.getY(), drone.getZ(), + new Explosions.Params(null, shot.user, w.explosion, w.explosionBlock, w.flaming, true, 0, false, null)); + sound(set, shot.shooter); + drone.ejectPassengers(); + drone.discard(); + } + return true; + } + sound(set, shot.shooter); + Vec3 motion = shot.shooter.getDeltaMovement(); + BombEntity e = spawn(level, ModEntities.BOMB.get(), set, shot, motion, shot.shooter.getYRot(), 0.0F, w.acceleration); + if (e == null) { + return false; + } + e.setDeltaMovement(motion); + level.addFreshEntity(e); + return true; + } + + // Missiles + + private static float fixedYaw(WeaponSet set, int mount) { + return set.mounts().get(mount).yaw(); + } + + private static float fixedPitch(WeaponSet set, int mount) { + return set.mounts().get(mount).pitch(); + } + + private static boolean aaMissile(ServerLevel level, WeaponSet set, int mount, Shot shot) { + Entity target = level.getEntity(shot.option1); + if (target == null || target.isRemoved()) { + return false; + } + sound(set, shot.shooter); + float yaw = shot.shooter.getYRot() + fixedYaw(set, mount); + float pitch = shot.shooter.getXRot() + fixedPitch(set, mount); + AAMissileEntity e = spawn(level, ModEntities.AA_MISSILE.get(), set, shot, forward(yaw, pitch), yaw, pitch, set.definition().acceleration); + if (e == null) { + return false; + } + e.setTarget(target); + level.addFreshEntity(e); + return true; + } + + /** Ray from the shooter along the aimer's view, as the original's AS missile and CAS sights. */ + private static @Nullable BlockHitResult sight(ServerLevel level, Shot shot, double eyeHeight, double range) { + Entity aimer = shot.aimer(); + Vec3 from = shot.shooter.position().add(0.0, eyeHeight, 0.0); + Vec3 to = from.add(forward(aimer.getYRot(), aimer.getXRot()).scale(range)); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shot.shooter)); + return hit.getType() == HitResult.Type.BLOCK ? hit : null; + } + + private static boolean nextToWater(ServerLevel level, BlockPos pos) { + if (level.getFluidState(pos).is(FluidTags.WATER)) { + return true; + } + for (Direction d : Direction.values()) { + if (level.getFluidState(pos.relative(d)).is(FluidTags.WATER)) { + return true; + } + } + return false; + } + + private static boolean asMissile(ServerLevel level, WeaponSet set, Shot shot) { + BlockHitResult hit = sight(level, shot, 1.62, 250.0); + if (hit == null || nextToWater(level, hit.getBlockPos())) { + return false; + } + Entity aimer = shot.aimer(); + ASMissileEntity e = spawn(level, ModEntities.AS_MISSILE.get(), set, shot, forward(aimer.getYRot(), aimer.getXRot()), + aimer.getYRot(), aimer.getXRot(), set.definition().acceleration); + if (e == null) { + return false; + } + e.targetX = hit.getLocation().x; + e.targetY = hit.getLocation().y; + e.targetZ = hit.getLocation().z; + level.addFreshEntity(e); + sound(set, shot.shooter); + return true; + } + + private static boolean atMissile(ServerLevel level, WeaponSet set, int mount, Shot shot) { + Entity target = level.getEntity(shot.option1); + if (target == null || target.isRemoved()) { + return false; + } + float yaw = shot.aimer().getYRot() + fixedYaw(set, mount); + float pitch = shot.shooter.getXRot() + fixedPitch(set, mount); + ATMissileEntity e = spawn(level, ModEntities.AT_MISSILE.get(), set, shot, forward(yaw, pitch), yaw, pitch, set.definition().acceleration); + if (e == null) { + return false; + } + e.setTarget(target); + e.guidanceType = shot.option2; + level.addFreshEntity(e); + sound(set, shot.shooter); + return true; + } + + private static boolean tvMissile(ServerLevel level, WeaponSet set, int mount, Shot shot) { + float yaw = shot.aimer().getYRot() + fixedYaw(set, mount); + float pitch = shot.aimer().getXRot() + fixedPitch(set, mount); + boolean tvGuided = shot.option1 == 0; + float acceleration = set.definition().acceleration * (tvGuided ? 1.0F : 1.5F); + TvMissileEntity e = spawn(level, ModEntities.TV_MISSILE.get(), set, shot, forward(yaw, pitch), yaw, pitch, acceleration); + if (e == null) { + return false; + } + level.addFreshEntity(e); + if (shot.shooter instanceof AircraftEntity vehicle) { + vehicle.setTvMissile(e); + } + sound(set, shot.shooter); + return true; + } + + private static boolean torpedo(ServerLevel level, WeaponSet set, Shot shot) { + WeaponDefinition w = set.definition(); + Vec3 vehicleMotion = shot.shooter.getDeltaMovement(); + float yaw = shot.yaw; + float pitch = shot.pitch; + Vec3 target = null; + if (w.guidedTorpedo) { + Entity aimer = shot.aimer(); + yaw = aimer.getYRot(); + pitch = aimer.getXRot(); + Vec3 from = aimer.position(); + Vec3 to = from.add(forward(yaw, pitch).scale(150.0)); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shot.shooter)); + if (hit.getType() != HitResult.Type.BLOCK || !nextToWater(level, hit.getBlockPos())) { + return false; + } + target = hit.getLocation(); + } + Vec3 m = forward(yaw, pitch).scale(w.acceleration).add(vehicleMotion); + double speed = m.length(); + TorpedoEntity e = spawn(level, ModEntities.TORPEDO.get(), set, shot, m, yaw, 0.0F, speed); + if (e == null) { + return false; + } + e.setDeltaMovement(m); + e.accelerationInWater = w.accelerationInWater; + if (target != null) { + e.targetX = target.x; + e.targetY = target.y; + e.targetZ = target.z; + } + level.addFreshEntity(e); + sound(set, shot.shooter); + return true; + } + + // Support weapons + + private static boolean markerRocket(ServerLevel level, WeaponSet set, Shot shot) { + sound(set, shot.shooter); + MarkerRocketEntity e = spawn(level, ModEntities.MARKER_ROCKET.get(), set, shot, forward(shot.yaw, shot.pitch), shot.yaw, shot.pitch, + set.definition().acceleration); + if (e == null) { + return false; + } + e.setStatus(1); + level.addFreshEntity(e); + return true; + } + + private static boolean dispenser(ServerLevel level, WeaponSet set, Shot shot) { + sound(set, shot.shooter); + DispensedItemEntity e = spawn(level, ModEntities.DISPENSED_ITEM.get(), set, shot, forward(shot.yaw, shot.pitch), shot.yaw, shot.pitch, + set.definition().acceleration); + if (e == null) { + return false; + } + Vec3 m = shot.shooter.getDeltaMovement().add(e.getDeltaMovement().scale(0.5)); + e.setDeltaMovement(m); + e.setPos(e.getX() + m.x * 0.5, e.getY() + m.y * 0.5, e.getZ() + m.z * 0.5); + level.addFreshEntity(e); + return true; + } + + /** + * Spots everything of the weapon's {@code Target} kinds inside its cone, which then glows for + * {@code MarkTime}. Marking a block for the team needs the team manager of a later phase. + */ + private static boolean targetingPod(ServerLevel level, WeaponSet set, Shot shot) { + WeaponDefinition w = set.definition(); + Entity aimer = shot.aimer(); + if ((w.target & WeaponDefinition.TARGET_BLOCK) != 0) { + sound(set, aimer, "ng"); + return true; + } + boolean spotted = false; + double range = Math.max(w.length, 1.0F); + for (LivingEntity entity : level.getEntitiesOfClass(LivingEntity.class, aimer.getBoundingBox().inflate(range))) { + if (entity == aimer || shot.shooter.hasPassenger(entity) || aimer.distanceToSqr(entity) > range * range + || !matchesTarget(entity, w.target) || !Guidance.inCone(aimer, aimer.getYRot(), aimer.getXRot(), entity, w.angle)) { + continue; + } + entity.addEffect(new MobEffectInstance(MobEffects.GLOWING, Math.max(w.markTime, 20))); + spotted = true; + } + sound(set, shot.shooter, spotted ? w.sound : "ng"); + return true; + } + + private static boolean matchesTarget(LivingEntity entity, int bits) { + Entity vehicle = entity.getVehicle(); + if (vehicle instanceof PlaneEntity) { + return (bits & WeaponDefinition.TARGET_PLANES) != 0; + } + if (vehicle instanceof HelicopterEntity) { + return (bits & WeaponDefinition.TARGET_HELICOPTERS) != 0; + } + if (vehicle instanceof TankEntity || vehicle instanceof GroundVehicleEntity) { + return (bits & WeaponDefinition.TARGET_GROUND_VEHICLES) != 0; + } + if (entity instanceof Player) { + return (bits & WeaponDefinition.TARGET_PLAYERS) != 0; + } + if (entity instanceof Enemy) { + return (bits & WeaponDefinition.TARGET_MONSTERS) != 0; + } + return (bits & WeaponDefinition.TARGET_OTHERS) != 0; + } + + // Close air support + + private static boolean cas(ServerLevel level, WeaponSet set, Shot shot) { + BlockHitResult hit = sight(level, shot, 2.0, 150.0); + if (hit == null) { + return false; + } + Vec3 at = hit.getLocation(); + set.casX = at.x; + set.casY = at.y; + set.casZ = at.z; + int direction = (int) Mth.positiveModulo(shot.aimer().getYRot() + 45.0F, 360.0F) / 90; + direction += RANDOM.nextBoolean() ? -1 : 1; + set.casDirection = Math.floorMod(direction, 4); + set.casUser = shot.user; + set.casShooter = shot.shooter; + set.casStartTick = 50; + set.casCount = 0; + if (shot.user instanceof ServerPlayer player) { + ModSounds.playTo(player, set.definition().sound, 1.0F, 1.0F); + } + return true; + } + + /** {@code MCH_WeaponCAS.update}: three passes, 45 ticks apart, the later ones offset to the sides. */ + static void tick(AircraftEntity vehicle, WeaponSet set) { + if (set.casCount >= 3 || set.countWait == 0 || set.ticksSinceShot(0) != set.casStartTick + || !(vehicle.level() instanceof ServerLevel level)) { + return; + } + double x = 0.0; + double z = 0.0; + if (set.casCount >= 1) { + double sign = set.casCount == 1 ? 1.0 : -1.0; + if (set.casDirection % 2 == 0) { + x = RANDOM.nextDouble() * 10.0 * sign; + z = (RANDOM.nextDouble() - 0.5) * 10.0; + } else { + z = RANDOM.nextDouble() * 10.0 * sign; + x = (RANDOM.nextDouble() - 0.5) * 10.0; + } + } + spawnA10(level, set, set.casX + x, set.casY + 20.0, set.casZ + z); + set.casStartTick = set.ticksSinceShot(0) + 45; + set.casCount++; + } + + private static void spawnA10(ServerLevel level, WeaponSet set, double x, double y, double z) { + double mx = 0.0; + double mz = 0.0; + switch (set.casDirection) { + case 0 -> { + mz = 3.0; + z -= 90.0; + } + case 1 -> { + mx = -3.0; + x += 90.0; + } + case 2 -> { + mz = -3.0; + z += 90.0; + } + default -> { + mx = 3.0; + x -= 90.0; + } + } + A10Entity a10 = ModEntities.A10.get().create(level); + if (a10 == null) { + return; + } + a10.setPos(x, y, z); + WeaponDefinition w = set.definition(); + a10.setup(set.name(), set.casDirection, mx, mz, set.casShooter, set.casUser); + a10.power = w.power; + a10.explosionPower = w.explosion; + a10.acceleration = w.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java new file mode 100644 index 0000000..a5dc19b --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -0,0 +1,596 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponBayPart; +import com.jnhprojects.mcheli.content.weapon.MuzzleFlash; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.WeaponStatePacket; +import net.minecraft.core.particles.DustParticleOptions; +import net.minecraft.core.particles.ItemParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * The armament of one vehicle: its weapon sets, which one each seat has selected, the flares, and + * the effects every client derives from the synced firing state. Ported from the weapon handling + * in {@code MCH_EntityAircraft} ({@code createWeapon}, {@code switchWeapon}, + * {@code useCurrentWeapon}, {@code updateWeapons} and {@code updateWeaponsRotation}). + */ +public final class VehicleWeapons { + private final AircraftEntity vehicle; + private final AircraftDefinition def; + private final boolean clientSide; + private final List sets = new ArrayList<>(); + private final int[] selected; + private final @Nullable Guidance[] guidance; + private final RandomSource random = RandomSource.create(); + + /** One bit per mount, set while that mount shows muzzle effects. Synced by the vehicle. */ + private int useStat; + private int prevUseStat; + + private final float[] bayAngle; + private final float[] prevBayAngle; + + private final int[] sentAmmo; + private final int[] sentRest; + + public final FlareDispenser flares; + + // Recoil that kicks the whole hull, from Recoil in the weapon file. + private int recoilCount; + private float recoilValue; + private float recoilYaw; + + public VehicleWeapons(AircraftEntity vehicle, AircraftDefinition def) { + this.vehicle = vehicle; + this.def = def; + this.clientSide = vehicle.level().isClientSide(); + 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)); + } + } + selected = new int[def.seatCount()]; + Arrays.fill(selected, -1); + guidance = new Guidance[sets.size()]; + for (int i = 0; i < sets.size(); i++) { + WeaponDefinition w = sets.get(i).definition(); + if (w.type == WeaponType.AA_MISSILE || w.type == WeaponType.AT_MISSILE) { + boolean aa = w.type == WeaponType.AA_MISSILE; + guidance[i] = new Guidance(aa, !aa, w.ridableOnly, w.lockTime, this::canLock); + } + } + bayAngle = new float[def.weaponBays.size()]; + prevBayAngle = new float[def.weaponBays.size()]; + sentAmmo = new int[sets.size()]; + sentRest = new int[sets.size()]; + Arrays.fill(sentAmmo, -1); + flares = new FlareDispenser(vehicle, def); + } + + public List sets() { + return sets; + } + + public @Nullable WeaponSet set(int id) { + return id >= 0 && id < sets.size() ? sets.get(id) : null; + } + + public int indexOf(String weapon) { + for (int i = 0; i < sets.size(); i++) { + if (sets.get(i).name().equalsIgnoreCase(weapon)) { + return i; + } + } + return -1; + } + + public @Nullable WeaponSet byName(String weapon) { + return set(indexOf(weapon)); + } + + public @Nullable Guidance guidance(int id) { + return id >= 0 && id < guidance.length ? guidance[id] : null; + } + + /** A vehicle does not lock its own crew or cargo. */ + private boolean canLock(Entity entity) { + return entity != vehicle && !vehicle.hasPassenger(entity); + } + + // Selection + + public int selected(int seat) { + return seat >= 0 && seat < selected.length ? selected[seat] : -1; + } + + public @Nullable WeaponSet current(int seat) { + return set(selected(seat)); + } + + /** The seat that operates a weapon: its own seat, or the pilot while that seat has no player. */ + public int operatorSeat(int id) { + WeaponSet set = set(id); + if (set == null) { + return -1; + } + WeaponMount mount = set.firstMount(); + if (mount.usableByPilot() && !(vehicle.occupant(mount.seatIndex()) instanceof Player)) { + return 0; + } + return mount.seatIndex(); + } + + /** {@code getNextWeaponID}: the next weapon {@code seat} may use, stepping forwards or back. */ + public int next(int seat, int step) { + if (sets.isEmpty() || seat < 0) { + return -1; + } + int id = selected(seat); + for (int i = 0; i < sets.size(); i++) { + id = step >= 0 ? (id + 1) % sets.size() : id > 0 ? id - 1 : sets.size() - 1; + WeaponMount mount = sets.get(id).firstMount(); + int owner = mount.seatIndex(); + if (owner == seat || seat == 0 && mount.usableByPilot() && !(vehicle.occupant(owner) instanceof Player)) { + return id; + } + } + return -1; + } + + /** {@code initCurrentWeapon}: a player took a seat. Server only. */ + public void onSeated(Entity entity, int seat) { + if (seat < 0 || seat >= selected.length) { + return; + } + selected[seat] = -1; + if (entity instanceof Player) { + switchTo(entity, seat, next(seat, 1)); + } else { + vehicle.publishWeaponSelection(); + } + } + + public void onLeft(int seat) { + if (seat >= 0 && seat < selected.length) { + selected[seat] = -1; + vehicle.publishWeaponSelection(); + } + } + + /** {@code switchWeapon}. Server only; the selection reaches clients through synced data. */ + public void switchTo(Entity user, int seat, int id) { + if (seat < 0 || seat >= selected.length || sets.isEmpty()) { + return; + } + WeaponSet before = current(seat); + if (before != null) { + before.reload(); + } + selected[seat] = Math.min(id, sets.size() - 1); + WeaponSet now = current(seat); + if (now != null) { + now.onSwitchWeapon(vehicle.hasInfiniteAmmo(user)); + } + vehicle.publishWeaponSelection(); + } + + public String encodeSelection() { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < selected.length; i++) { + if (i > 0) { + b.append(','); + } + b.append(selected[i]); + } + return b.toString(); + } + + /** Applies the server's selection on a client, running the switch delay where it changed. */ + public void decodeSelection(String encoded, @Nullable Entity localPlayer) { + String[] parts = encoded.isEmpty() ? new String[0] : encoded.split(","); + for (int i = 0; i < selected.length; i++) { + int id; + try { + id = i < parts.length ? Integer.parseInt(parts[i]) : -1; + } catch (NumberFormatException e) { + id = -1; + } + if (selected[i] != id) { + selected[i] = id; + WeaponSet set = set(id); + Entity occupant = vehicle.occupant(i); + if (set != null && occupant != null && occupant == localPlayer) { + set.onSwitchWeapon(vehicle.hasInfiniteAmmo(occupant)); + Guidance g = guidance(id); + if (g != null) { + g.clear(); + } + } + } + } + } + + // Firing + + /** + * {@code useCurrentWeapon}: fires the selected weapon of {@code seat} if it is ready. The + * trigger decides what the shot does on this side. + */ + public boolean use(Entity user, int seat, Shot shot, WeaponSet.Trigger trigger) { + int id = selected(seat); + WeaponSet set = set(id); + if (set == null || !set.canUse() || vehicle.isDestroyed()) { + return false; + } + shot.infiniteAmmo = vehicle.hasInfiniteAmmo(user); + int mount = set.currentMount(); + // As the original did, face the vehicle where the user looks for the length of the shot. + boolean followUser = vehicle.aimsWithUser(set); + float hullYaw = vehicle.getYRot(); + float hullPitch = vehicle.getXRot(); + if (followUser) { + vehicle.setYRot(user.getYRot()); + vehicle.setXRot(user.getXRot()); + } + boolean fired; + try { + fired = set.use(shot, (s, m, sh) -> { + Vec3 muzzle = muzzleOffset(s, m, 1.0F); + sh.x += muzzle.x; + sh.y += muzzle.y; + sh.z += muzzle.z; + return trigger.fire(s, m, sh); + }); + } finally { + if (followUser) { + vehicle.setYRot(hullYaw); + vehicle.setXRot(hullPitch); + } + } + if (!fired) { + return false; + } + String group = set.definition().group; + if (!group.isEmpty()) { + for (WeaponSet other : sets) { + if (other != set && group.equals(other.definition().group)) { + other.waitForGroup(shot.reload); + } + } + } + if (!clientSide) { + int bit = bitOf(id, mount); + if (bit < 32) { + useStat |= 1 << bit; + } + } + return true; + } + + /** World offset of a mount from the vehicle origin; turret mounts turn with the turret. */ + public Vec3 muzzleOffset(WeaponSet set, int mount, float partialTicks) { + WeaponMount m = set.mounts().get(mount); + Vector3 local = m.turret() ? vehicle.onTurret(def, m.position(), partialTicks) : m.position(); + Vector3f v = vehicle.toWorldOffset(local); + return new Vec3(v.x, v.y, v.z); + } + + private int bitOf(int id, int mount) { + int shift = 0; + for (int i = 0; i < id; i++) { + shift += sets.get(i).mounts().size(); + } + return shift + mount; + } + + /** Whether a mount is cooling down after a shot; missile parts are hidden meanwhile. */ + public boolean isMountUsed(int id, int mount) { + int bit = bitOf(id, mount); + return bit < 32 && (useStat & 1 << bit) != 0; + } + + public int useStat() { + return useStat; + } + + public void setUseStat(int stat) { + useStat = stat; + } + + public void switchMode(int seat) { + WeaponSet set = current(seat); + if (set != null) { + set.switchMode(); + } + } + + // Ticking + + /** {@code updateWeapons} and {@code updateWeaponsRotation}, once per tick on both sides. */ + public void tick() { + if (sets.isEmpty()) { + flares.tick(); + return; + } + int previous = prevUseStat; + if (!clientSide) { + useStat |= usedStat(); + vehicle.publishWeaponUse(useStat); + } else { + useStat = vehicle.syncedWeaponUse(); + } + prevUseStat = useStat; + + float yaw = Mth.wrapDegrees(vehicle.getYRot()); + float pitch = Mth.wrapDegrees(vehicle.getXRot()); + int bit = 0; + for (int id = 0; id < sets.size(); id++) { + WeaponSet set = sets.get(id); + boolean longDelay = set.isLongDelay(); + boolean anyUsed = false; + for (int mount = 0; mount < set.mounts().size(); mount++) { + boolean wasUsed = bit < 32 && (previous & 1 << bit) != 0; + boolean used = bit < 32 && (useStat & 1 << bit) != 0; + if (longDelay && wasUsed && used) { + used = false; + } + anyUsed |= used; + if (!wasUsed && used && set.definition().recoil > 0.0F) { + recoilCount = 30; + recoilValue = set.definition().recoil; + recoilYaw = set.rotationYaw; + } + if (clientSide && used) { + muzzleEffects(set, mount, yaw); + } + set.tickRecoil(mount, used); + bit++; + } + set.tick(anyUsed); + if (!clientSide) { + ServerFiring.tick(vehicle, set); + } + updateRotation(set); + } + if (!clientSide) { + useStat = 0; + syncAmmo(); + } else { + for (Guidance g : guidance) { + if (g != null) { + g.tick(); + } + } + } + if (recoilCount > 0) { + recoilCount--; + } + tickBays(); + if (clientSide) { + smoke(); + } + flares.tick(); + } + + private int usedStat() { + int stat = 0; + int bit = 0; + for (WeaponSet set : sets) { + for (int mount = 0; mount < set.mounts().size() && bit < 32; mount++) { + stat |= set.isUsed(mount) ? 1 << bit : 0; + bit++; + } + } + return stat; + } + + /** Turret aim of one set, following whoever operates it, within the mount's traverse limits. */ + private void updateRotation(WeaponSet set) { + if (vehicle.isDestroyed()) { + return; + } + WeaponMount mount = set.firstMount(); + int seat = mount.seatIndex(); + Entity operator = vehicle.occupant(seat); + if (mount.usableByPilot() && !(operator instanceof Player)) { + operator = vehicle.occupant(0); + } + float hullYaw = Mth.wrapDegrees(vehicle.getYRot()); + float hullPitch = Mth.wrapDegrees(vehicle.getXRot()); + float turretYaw = vehicle.turretYaw(); + if (operator instanceof Player) { + if ((int) mount.minYaw() != 0 || (int) mount.maxYaw() != 0) { + float ty = mount.turret() ? turretYaw : 0.0F; + float ey = Mth.wrapDegrees(operator.getYRot() - hullYaw - mount.defaultYaw() - ty); + if (Math.abs((int) mount.minYaw()) < 360 && Math.abs((int) mount.maxYaw()) < 360) { + float targetYaw = Mth.clamp(ey, mount.minYaw(), mount.maxYaw()); + float wy = set.rotationYaw - mount.defaultYaw() - ty; + if (targetYaw < wy) { + wy = wy - targetYaw > 15.0F ? wy - 15.0F : targetYaw; + } else if (targetYaw > wy) { + wy = targetYaw - wy > 15.0F ? wy + 15.0F : targetYaw; + } + set.rotationYaw = wy + mount.defaultYaw() + ty; + } else { + set.rotationYaw = ey + ty; + } + } + float ep = Mth.wrapDegrees(operator.getXRot() - hullPitch); + set.rotationPitch = Mth.clamp(ep, mount.minPitch(), mount.maxPitch()); + set.rotationTurretYaw = 0.0F; + } else { + set.rotationTurretYaw = turretYaw; + } + } + + /** {@code updateRecoil}: the hull kick of a big gun, applied on the simulating side. */ + public float @Nullable [] recoilKick() { + if (recoilCount < 12) { + return null; + } + float rel = (float) Math.toRadians(recoilYaw - vehicle.roll()); + float kick = Mth.cos((float) Math.toRadians(recoilCount * 6)) * recoilValue; + return new float[]{kick * Mth.cos(rel), kick * Mth.sin(rel)}; + } + + /** {@code updateWeaponBay}: bays swing open while a seated crew member has their weapon selected. */ + private void tickBays() { + for (int i = 0; i < bayAngle.length; i++) { + WeaponBayPart bay = def.weaponBays.get(i); + boolean open = false; + for (String weapon : bay.weapons()) { + int id = indexOf(weapon); + for (int seat = 0; seat < selected.length && id >= 0; seat++) { + if (selected[seat] == id && vehicle.occupant(seat) != null) { + open = true; + } + } + } + prevBayAngle[i] = bayAngle[i]; + bayAngle[i] = Mth.clamp(bayAngle[i] + (open ? 3.0F : -3.0F), 0.0F, 90.0F); + } + } + + public float bayAngle(int bay, float partialTicks) { + return bay < bayAngle.length ? Mth.lerp(partialTicks, prevBayAngle[bay], bayAngle[bay]) : 0.0F; + } + + // Effects, client only + + /** {@code spawnParticleMuzzleFlash} plus the spent case of guns with {@code SetCartridge}. */ + private void muzzleEffects(WeaponSet set, int mount, float hullYaw) { + Level level = vehicle.level(); + WeaponDefinition w = set.definition(); + Vec3 offset = muzzleOffset(set, mount, 1.0F); + double px = vehicle.getX() + offset.x; + double py = vehicle.getY() + offset.y; + double pz = vehicle.getZ() + offset.z; + Vec3 dir = Vec3.directionFromRotation(vehicle.getXRot() + set.rotationPitch, hullYaw + set.rotationYaw); + for (MuzzleFlash mf : w.muzzleSmokes) { + double x = px + dir.x * mf.distance(); + double y = py + dir.y * mf.distance(); + double z = pz + dir.z * mf.distance(); + for (int i = 0; i < mf.count(); i++) { + double r = mf.range(); + level.addParticle(mf.size() >= 2.0F ? ParticleTypes.LARGE_SMOKE : ParticleTypes.SMOKE, + x + (random.nextDouble() - 0.5) * r, y + (random.nextDouble() - 0.5) * r, z + (random.nextDouble() - 0.5) * r, + (random.nextDouble() - 0.5) * 0.05, 0.02, (random.nextDouble() - 0.5) * 0.05); + } + } + for (MuzzleFlash mf : w.muzzleFlashes) { + int rgb = mf.color().argb() & 0xFFFFFF; + level.addParticle(new DustParticleOptions(Vec3.fromRGB24(rgb).toVector3f(), Mth.clamp(mf.size() * 4.0F, 0.5F, 4.0F)), + px + dir.x * mf.distance(), py + dir.y * mf.distance(), pz + dir.z * mf.distance(), 0.0, 0.0, 0.0); + } + if (w.cartridge != null) { + Vec3 motion = vehicle.getDeltaMovement(); + Vec3 side = Vec3.directionFromRotation(0.0F, hullYaw + set.rotationYaw + w.cartridge.yaw() + 90.0F) + .scale(0.1 + w.cartridge.acceleration()); + level.addParticle(new ItemParticleOption(ParticleTypes.ITEM, new ItemStack(Items.GOLD_NUGGET)), + px, py, pz, motion.x + side.x, motion.y + 0.1, motion.z + side.z); + } + } + + /** {@code onUpdate_ParticleSmoke}: smoke generators trail while the pilot has them selected. */ + private void smoke() { + WeaponSet set = current(0); + if (set == null || set.definition().type != WeaponType.SMOKE || vehicle.visibleThrottle() <= 0.1F) { + return; + } + WeaponDefinition w = set.definition(); + Vec3 back = Vec3.directionFromRotation(vehicle.getXRot(), vehicle.getYRot()).reverse(); + int rgb = w.color.argb() & 0xFFFFFF; + for (int mount = 0; mount < set.mounts().size(); mount++) { + if (random.nextFloat() > vehicle.visibleThrottle() * 1.5F) { + continue; + } + Vec3 offset = muzzleOffset(set, mount, 1.0F); + for (int i = 0; i < w.smokeNum; i++) { + vehicle.level().addParticle(new DustParticleOptions(Vec3.fromRGB24(rgb).toVector3f(), Mth.clamp(w.smokeSize * 2.0F, 0.5F, 4.0F)), + vehicle.getX() + offset.x + back.x, vehicle.getY() + offset.y + back.y, vehicle.getZ() + offset.z + back.z, + back.x * w.acceleration + (random.nextDouble() - 0.5) * 0.2, + back.y * w.acceleration + (random.nextDouble() - 0.5) * 0.2, + back.z * w.acceleration + (random.nextDouble() - 0.5) * 0.2); + } + } + } + + // Ammunition + + /** Sends magazine and reserve counts to the crew whenever they change. Server only. */ + private void syncAmmo() { + for (int id = 0; id < sets.size(); id++) { + WeaponSet set = sets.get(id); + if (set.ammo() != sentAmmo[id] || set.restAmmo() != sentRest[id]) { + sentAmmo[id] = set.ammo(); + sentRest[id] = set.restAmmo(); + WeaponStatePacket packet = new WeaponStatePacket(vehicle.getId(), id, set.ammo(), set.restAmmo()); + for (Entity passenger : vehicle.getPassengers()) { + if (passenger instanceof ServerPlayer player) { + ModNetwork.sendToPlayer(player, packet); + } + } + } + } + } + + /** Forces a full resend, for a player who just boarded. */ + public void resendAmmo() { + Arrays.fill(sentAmmo, -1); + } + + public void applyAmmo(int id, int ammo, int rest) { + WeaponSet set = set(id); + if (set != null) { + set.setAmmo(ammo); + set.setRestAmmo(rest); + } + } + + /** {@code reloadAllWeapon}: tops up every magazine from its reserve. */ + public void reloadAll() { + for (WeaponSet set : sets) { + set.reloadMagazine(); + } + } + + public int[] saveAmmo() { + int[] out = new int[sets.size() * 2]; + for (int i = 0; i < sets.size(); i++) { + out[i * 2] = sets.get(i).ammo(); + out[i * 2 + 1] = sets.get(i).restAmmo(); + } + return out; + } + + public void loadAmmo(int[] saved) { + for (int i = 0; i < sets.size() && i * 2 + 1 < saved.length; i++) { + sets.get(i).setAmmo(Math.min(saved[i * 2], sets.get(i).magazineSize())); + sets.get(i).setRestAmmo(saved[i * 2 + 1]); + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java rename to minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java diff --git a/forge-26.3/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-1.21.1/src/main/resources/assets/mcheli/particles/glow.json similarity index 100% rename from forge-26.3/src/main/resources/assets/mcheli/particles/glow.json rename to minecraft-1.21.1/src/main/resources/assets/mcheli/particles/glow.json diff --git a/forge-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-1.21.1/src/main/resources/assets/mcheli/textures/particle/glow.png similarity index 100% rename from forge-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png rename to minecraft-1.21.1/src/main/resources/assets/mcheli/textures/particle/glow.png diff --git a/forge-26.3/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-1.21.1/src/main/resources/data/mcheli/damage_type/bullet.json similarity index 100% rename from forge-26.3/src/main/resources/data/mcheli/damage_type/bullet.json rename to minecraft-1.21.1/src/main/resources/data/mcheli/damage_type/bullet.json diff --git a/forge-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-1.21.1/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json similarity index 100% rename from forge-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json rename to minecraft-1.21.1/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json diff --git a/forge-26.3/src/main/resources/mcheli_logo.png b/minecraft-1.21.1/src/main/resources/mcheli_logo.png similarity index 100% rename from forge-26.3/src/main/resources/mcheli_logo.png rename to minecraft-1.21.1/src/main/resources/mcheli_logo.png diff --git a/minecraft-1.21.1/src/main/resources/pack.mcmeta b/minecraft-1.21.1/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..de03af8 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "${mod_name} resources", + "pack_format": 34, + "supported_formats": [34, 48] + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 0000000..ff25884 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java @@ -0,0 +1,59 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.config.ConfigSpec; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Per player look and feel, in {@code config/mcheli-client.toml}. Nothing here changes how the + * game plays, so each player picks for themselves and the server never sees it. + */ +public final class ClientConfig { + public static final ConfigSpec SPEC; + + private static final ConfigSpec.BooleanValue NIGHT_GLOW; + private static final ConfigSpec.IntValue PACK_CACHE_SIZE; + + static { + ConfigSpec.Builder builder = ConfigSpec.builder(); + + builder.push("effects"); + NIGHT_GLOW = builder + .comment("Whether tracers, rocket motors, flares and explosions glow. The glow is faint by day", + "and strongest at night, in caves and under water.") + .define("nightGlow", true); + builder.pop(); + + builder.push("packs"); + PACK_CACHE_SIZE = builder + .comment("How much the content packs downloaded from servers may take up in mcheli/cache, in megabytes.", + "The packs used longest ago are deleted first; the current server's packs are always kept.", + "A single pack larger than this is not downloaded.") + .defineInRange("cacheSize", 256, 16, 16384); + builder.pop(); + + SPEC = builder.build(); + } + + private ClientConfig() { + } + + /** Reads {@code config/mcheli-client.toml}, writing it with the defaults if it is new. */ + public static void load(Path configDir) { + Path file = configDir.resolve(MCHeli.MOD_ID + "-client.toml"); + try { + SPEC.load(file); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not read {}; using the defaults", file, e); + } + } + + public static boolean nightGlow() { + return SPEC.isLoaded() ? NIGHT_GLOW.get() : NIGHT_GLOW.getDefault(); + } + + public static long packCacheBytes() { + return (long) (SPEC.isLoaded() ? PACK_CACHE_SIZE.get() : PACK_CACHE_SIZE.getDefault()) << 20; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 0000000..d7b91d8 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.network.PackSync; +import com.mojang.brigadier.CommandDispatcher; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.packs.repository.RepositorySource; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.storage.LevelResource; + +import java.util.List; + +/** + * What the mod does on game events that both sides see. Each loader module calls these from its + * own events; nothing here knows which loader that is. + */ +public final class CommonHooks { + private CommonHooks() { + } + + /** + * Adds the imported original content to the resource packs (client) and data packs (server). + * Without the original zip there is nothing to add. + */ + public static RepositorySource packSource() { + return onLoad -> { + // Fabric builds some pack repositories before our initializer has run. + LegacyContent content = MCHeli.legacyContent(); + if (content != null) { + content.pack().ifPresent(onLoad); + } + }; + } + + /** Before the worlds load: read the world's server config, then load the content packs it allows. */ + public static void serverAboutToStart(MinecraftServer server) { + ServerConfig.load(server.getWorldPath(LevelResource.ROOT)); + MCHeli.content().replaceWith(MCHeli.packs().load(true)); + } + + public static void serverStopped() { + ServerConfig.unload(); + } + + public static void serverTickEnd(MinecraftServer server) { + PackSync.pump(server); + } + + public static void registerCommands(CommandDispatcher dispatcher) { + McheliCommand.register(dispatcher); + } + + /** + * The vehicle recipes live in {@code data/mcheli/recipe} of the imported content pack. The + * original listed them on its drafting table; until that is ported, every MCHeli recipe goes + * into a player's recipe book when they join, so they can be found. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() + .filter(holder -> holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID)) + .toList(); + player.awardRecipes(ours); + } + + public static void playerLoggedOut(ServerPlayer player) { + PackSync.onLogout(player); + } +} 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 new file mode 100644 index 0000000..c378489 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,151 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackFolder; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import net.minecraft.network.chat.Component; +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.function.Consumer; + +/** + * The content packs in {@code /mcheli/packs}, loaded together with the imported 1.0.3 + * content, which always comes first. + * + *

At startup every pack in the folder is loaded, so the vehicles exist before any world does. + * When a server starts (single player included) and on {@code /mcheli reload}, the folder is + * scanned again and the server config applied: the allow list, the size limit per pack and the + * limit for all packs together. The packs that pass are what players get sent. + * + *

A zip pack is read in place, so packs stay open while their definitions and assets are in + * use. The set a reload replaces is closed on the reload after that, so a client in the same game + * that is still drawing from it never reads from a closed zip. + */ +public final class ContentPacks { + private static final int PROBLEMS_IN_CHAT = 8; + + private final Path folder; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.legacy = legacy; + } + + /** {@code /mcheli/packs}. */ + public Path folder() { + return folder; + } + + /** The packs whose definitions are loaded, in load order. The import is not among them. */ + public List active() { + return active; + } + + /** Everything that went wrong in the last load, packs left out included. */ + public List problems() { + return problems; + } + + /** + * Scans the folder and loads the import and the packs. Never throws; whatever goes wrong is + * logged and kept in {@link #problems()}. + * + * @param serverRules whether to apply the server config; off at startup, when no world exists + * @return the new content, for the caller to swap in + */ + public synchronized ContentRegistry load(boolean serverRules) { + try { + Files.createDirectories(folder); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not create {}", folder, e); + } + + PackFolder.Scan scan = PackFolder.scan(folder); + List found = new ArrayList<>(scan.problems()); + List accepted = new ArrayList<>(); + long total = 0; + for (ContentPack pack : scan.packs()) { + long size; + String refusal; + try { + size = pack.archiveSize(); + refusal = serverRules ? refusal(pack, size, total) : null; + } catch (IOException e) { + size = 0; + refusal = "left out, could not be read: " + e; + } + if (refusal == null) { + accepted.add(pack); + total += size; + } else { + found.add(new PackProblem(pack.id(), refusal)); + close(List.of(pack)); + } + } + for (PackProblem problem : found) { + MCHeli.LOGGER.warn("Content pack {}", problem); + } + + List sources = new ArrayList<>(); + sources.add(new ContentSource(ContentSource.ORIGINAL, legacy.assets())); + for (ContentPack pack : accepted) { + sources.add(pack.contentSource()); + } + ContentRegistry registry = ContentRegistry.load(sources); + found.addAll(registry.problems()); + + close(retired); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + 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)"; + } + if (size > ServerConfig.maxPackBytes()) { + return "left out, %.1f MB is more than the server allows for one pack (packs.maxPackSize, %d MB)" + .formatted(size / 1048576.0, ServerConfig.maxPackBytes() >> 20); + } + if (total + size > ServerConfig.maxTotalPackBytes()) { + return "left out, it would take the packs past the server's total limit (packs.maxTotalSize, %d MB)" + .formatted(ServerConfig.maxTotalPackBytes() >> 20); + } + return null; + } + + /** Sends the problems of the last load as chat lines, the first few of them. */ + public void reportProblems(Consumer out) { + List list = problems; + for (int i = 0; i < Math.min(list.size(), PROBLEMS_IN_CHAT); i++) { + out.accept(Component.translatable("message.mcheli.pack.problem", list.get(i).pack(), list.get(i).message())); + } + if (list.size() > PROBLEMS_IN_CHAT) { + out.accept(Component.translatable("message.mcheli.pack.more_problems", list.size() - PROBLEMS_IN_CHAT)); + } + } + + private static void close(List packs) { + for (ContentPack pack : packs) { + try { + pack.close(); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not close content pack {}", pack, e); + } + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 0000000..7846b7b --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java @@ -0,0 +1,28 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.network.PackSync; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelResource; + +/** + * Reads the vehicle and weapon definitions from disk again while the game runs, for + * {@code /mcheli reload}: the server config, then the imported content and the content packs. + * Vehicles in the world pick up their new definition on their next tick (see + * {@code AircraftEntity#definition}). Every player gets the new pack list, downloads what changed + * and rebuilds their content the same way. + */ +public final class ContentReload { + private ContentReload() { + } + + /** @return the newly loaded content, already in use */ + public static ContentRegistry reload(MinecraftServer server) { + ServerConfig.load(server.getWorldPath(LevelResource.ROOT)); + ContentRegistry fresh = MCHeli.packs().load(true); + MCHeli.content().replaceWith(fresh); + MCHeli.LOGGER.info("Content reloaded: {} vehicles, {} weapons", fresh.all().size(), fresh.weapons().size()); + PackSync.sendListToAll(server); + return fresh; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java similarity index 87% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java index b98c15e..495bb50 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli; +import com.jnhprojects.mcheli.content.ImportTarget; import com.jnhprojects.mcheli.content.LegacyImporter; import net.minecraft.network.chat.Component; import net.minecraft.server.packs.PackLocationInfo; @@ -9,7 +10,6 @@ import net.minecraft.server.packs.repository.PackCompatibility; import net.minecraft.server.packs.repository.PackSource; import net.minecraft.world.flag.FeatureFlagSet; -import net.minecraftforge.event.AddPackFindersEvent; import org.jspecify.annotations.Nullable; import java.io.IOException; @@ -44,6 +44,8 @@ public final class LegacyContent { private static final String PACK_ID = "mcheli_content"; private static final String MARKER = "import.properties"; + /** The pack layout this Minecraft version reads. */ + private static final ImportTarget TARGET = ImportTarget.MODERN; private final Path folder; private final Path packRoot; @@ -76,22 +78,22 @@ public Optional problem() { return Optional.ofNullable(problem); } - /** Adds the imported folder to both the client resource packs and the server data packs. */ - void registerPacks() { - AddPackFindersEvent.BUS.addListener(event -> { - if (!Files.isDirectory(packRoot)) { - return; - } - PackLocationInfo location = new PackLocationInfo(PACK_ID, - Component.translatable("pack.mcheli.content"), PackSource.BUILT_IN, Optional.empty()); - Pack.Metadata metadata = new Pack.Metadata(Component.translatable("pack.mcheli.content.description"), - PackCompatibility.COMPATIBLE, FeatureFlagSet.of(), List.of()); - // Required, so it is enabled in every world and cannot be switched off. It sits at the - // bottom so that regular resource packs can still replace its textures and models. - Pack pack = new Pack(location, new PathPackResources.PathResourcesSupplier(packRoot), metadata, - new PackSelectionConfig(true, Pack.Position.BOTTOM, false)); - event.addRepositorySource(onLoad -> onLoad.accept(pack)); - }); + /** + * The imported folder as a pack, for both the client resource packs and the server data packs. + * Empty when nothing was imported. + */ + public Optional pack() { + if (!Files.isDirectory(packRoot)) { + return Optional.empty(); + } + PackLocationInfo location = new PackLocationInfo(PACK_ID, + Component.translatable("pack.mcheli.content"), PackSource.BUILT_IN, Optional.empty()); + Pack.Metadata metadata = new Pack.Metadata(Component.translatable("pack.mcheli.content.description"), + PackCompatibility.COMPATIBLE, FeatureFlagSet.of(), List.of()); + // Required, so it is enabled in every world and cannot be switched off. It sits at the + // bottom so that regular resource packs can still replace its textures and models. + return Optional.of(new Pack(location, new PathPackResources.PathResourcesSupplier(packRoot), metadata, + new PackSelectionConfig(true, Pack.Position.BOTTOM, false))); } private void importIfNeeded() throws IOException { @@ -115,7 +117,7 @@ private void importIfNeeded() throws IOException { MCHeli.LOGGER.info("Importing MCHeli content from {}", zip); Path staging = folder.resolve("imported.tmp"); deleteTree(staging); - LegacyImporter.Summary summary = LegacyImporter.run(zip, staging); + LegacyImporter.Summary summary = LegacyImporter.run(zip, staging, TARGET); try (OutputStream out = Files.newOutputStream(staging.resolve(MARKER))) { source.store(out, "Written by MCHeli Rework. Delete this folder to import again."); } @@ -206,6 +208,7 @@ private static void deleteQuietly(Path root) { private static Properties describe(Path zip) throws IOException { Properties p = new Properties(); p.setProperty("format", Integer.toString(LegacyImporter.FORMAT)); + p.setProperty("target", TARGET.name()); p.setProperty("source", zip.toAbsolutePath().normalize().toString()); p.setProperty("size", Long.toString(Files.size(zip))); p.setProperty("modified", Long.toString(Files.getLastModifiedTime(zip).toMillis())); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 0000000..21d4185 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModCreativeTabs; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.registry.ModParticles; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The mod, minus the loader. Each loader module's entry point installs its {@link Platform}, calls + * {@link #init()}, registers what {@code Registration} collected and wires its events to + * {@link CommonHooks} (and, on a client, {@code ClientHooks}). + */ +public final class MCHeli { + public static final String MOD_ID = "mcheli"; + public static final Logger LOGGER = LoggerFactory.getLogger(MCHeli.class); + + private static LegacyContent legacyContent; + private static ContentPacks packs; + private static ContentRegistry content; + + private MCHeli() { + } + + /** + * Imports the original content if needed, loads the definitions and declares the registry + * entries. The vehicle list must be known here: 1.0.0 had an item per vehicle, and those items + * are still registered so old worlds keep them. + */ + public static void init() { + Platform platform = Platform.get(); + LOGGER.info("MCHeli Rework {} on {}", platform.modVersion(), platform.loaderName()); + legacyContent = LegacyContent.prepare(platform.gameDir()); + packs = new ContentPacks(platform.gameDir(), legacyContent); + content = packs.load(false); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModItems.init(content); + ModCreativeTabs.init(); + } + + /** + * Vehicle and weapon definitions, from the imported original content and the content packs. + * Available from mod construction onwards. Always the same object; a reload changes what it + * holds. + */ + public static ContentRegistry content() { + return content; + } + + /** Where the original vehicle content came from, and why it is missing if it is. */ + public static LegacyContent legacyContent() { + return legacyContent; + } + + /** The content packs in {@code mcheli/packs} and the ones in use. */ + public static ContentPacks packs() { + return packs; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java similarity index 96% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java index 0e4c226..78a3612 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -17,7 +17,6 @@ import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Prediction; -import net.minecraftforge.event.RegisterCommandsEvent; import java.util.Collection; import java.util.List; @@ -43,11 +42,7 @@ public final class McheliCommand { private McheliCommand() { } - public static void register() { - RegisterCommandsEvent.BUS.addListener(event -> register(event.getDispatcher())); - } - - private static void register(CommandDispatcher dispatcher) { + public static void register(CommandDispatcher dispatcher) { dispatcher.register(Commands.literal(MCHeli.MOD_ID) .executes(McheliCommand::status) .then(Commands.literal("list") diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java 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 new file mode 100644 index 0000000..eef7dc3 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.config.ConfigSpec; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Server rules, the part of the original {@code mcheli.cfg} that decides how the game plays. + * The file lives per world in {@code serverconfig/mcheli-server.toml}, where Forge kept it, and is + * never sent to clients (only the server applies these rules). It is read when the world starts + * and again on {@code /mcheli reload}. + * + *

The values only exist while a world is loaded. Before that, and on clients, the getters return + * the defaults, which match 1.0.0. + */ +public final class ServerConfig { + public static final ConfigSpec SPEC; + + private static final ConfigSpec.BooleanValue EXPLOSIONS_BREAK_BLOCKS; + private static final ConfigSpec.BooleanValue EXPLOSIONS_START_FIRES; + private static final ConfigSpec.DoubleValue VEHICLE_DAMAGE_FACTOR; + private static final ConfigSpec.BooleanValue INFINITE_AMMO; + private static final ConfigSpec.StringListValue DISABLED_VEHICLES; + private static final ConfigSpec.StringListValue PACK_ALLOW_LIST; + private static final ConfigSpec.IntValue MAX_PACK_SIZE; + private static final ConfigSpec.IntValue MAX_TOTAL_SIZE; + + static { + ConfigSpec.Builder builder = ConfigSpec.builder(); + + builder.push("explosions"); + EXPLOSIONS_BREAK_BLOCKS = builder + .comment("Whether weapons and crashing vehicles break blocks. The mobGriefing game rule also applies.", + "Original: Explosion_DestroyBlock") + .define("breakBlocks", true); + EXPLOSIONS_START_FIRES = builder + .comment("Whether incendiary weapons and crashes set blocks on fire. Original: Explosion_FlamingBlock") + .define("startFires", true); + builder.pop(); + + builder.push("vehicles"); + VEHICLE_DAMAGE_FACTOR = builder + .comment("Multiplies the damage every vehicle takes, after armour. 0.5 makes them twice as tough.") + .defineInRange("damageFactor", 1.0, 0.0, 100.0); + DISABLED_VEHICLES = builder + .comment("Vehicles that cannot be placed, by definition name (the file name, such as \"ah-64\").", + "Vehicles already in the world stay.") + .defineList("disabled", List.of()); + builder.pop(); + + builder.push("weapons"); + INFINITE_AMMO = builder + .comment("Whether vehicle weapons never run out of ammunition. Original: InfinityAmmo") + .define("infiniteAmmo", false); + builder.pop(); + + builder.comment("Content packs from /mcheli/packs. Players get the packs from the server when they join.", + "Changes apply when the world starts or after /mcheli reload.") + .push("packs"); + PACK_ALLOW_LIST = builder + .comment("Pack ids the server loads, such as \"rework-example\". Empty loads every pack in the folder.") + .defineList("allowList", List.of()); + MAX_PACK_SIZE = builder + .comment("Largest pack the server loads and sends to players, in megabytes.") + .defineInRange("maxPackSize", 8, 1, 512); + MAX_TOTAL_SIZE = builder + .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); + builder.pop(); + + SPEC = builder.build(); + } + + private ServerConfig() { + } + + /** Reads the world's file, writing it with the defaults if it is new. */ + public static void load(Path worldDir) { + Path file = worldDir.resolve("serverconfig").resolve(MCHeli.MOD_ID + "-server.toml"); + try { + SPEC.load(file); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not read {}; using the defaults", file, e); + } + } + + /** The world closed: back to the defaults. */ + public static void unload() { + SPEC.unload(); + } + + public static boolean explosionsBreakBlocks() { + return SPEC.isLoaded() ? EXPLOSIONS_BREAK_BLOCKS.get() : EXPLOSIONS_BREAK_BLOCKS.getDefault(); + } + + public static boolean explosionsStartFires() { + return SPEC.isLoaded() ? EXPLOSIONS_START_FIRES.get() : EXPLOSIONS_START_FIRES.getDefault(); + } + + public static float vehicleDamageFactor() { + double factor = SPEC.isLoaded() ? VEHICLE_DAMAGE_FACTOR.get() : VEHICLE_DAMAGE_FACTOR.getDefault(); + return (float) factor; + } + + public static boolean infiniteAmmo() { + return SPEC.isLoaded() ? INFINITE_AMMO.get() : INFINITE_AMMO.getDefault(); + } + + /** Whether the allow list lets the server load this pack. An empty list allows every pack. */ + public static boolean isPackAllowed(String id) { + if (!SPEC.isLoaded() || PACK_ALLOW_LIST.get().isEmpty()) { + return true; + } + for (String allowed : PACK_ALLOW_LIST.get()) { + if (allowed.trim().toLowerCase(Locale.ROOT).equals(id)) { + return true; + } + } + return false; + } + + public static long maxPackBytes() { + return (long) (SPEC.isLoaded() ? MAX_PACK_SIZE.get() : MAX_PACK_SIZE.getDefault()) << 20; + } + + public static long maxTotalPackBytes() { + return (long) (SPEC.isLoaded() ? MAX_TOTAL_SIZE.get() : MAX_TOTAL_SIZE.getDefault()) << 20; + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 0000000..ab62e12 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.client; + +import net.minecraft.client.Camera; + +/** + * The camera's angles for this frame, after vanilla placed the camera and before it renders, as + * the loader module hands them to {@link ClientHooks#computeCameraAngles}. Forge and NeoForge + * back this with their viewport event; Fabric with a mixin at the same spot. + */ +public interface CameraAngles { + Camera camera(); + + float partialTick(); + + float yaw(); + + float pitch(); + + float roll(); + + void setYaw(float yaw); + + void setPitch(float pitch); + + void setRoll(float roll); +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 0000000..70bcc23 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java @@ -0,0 +1,28 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.registry.ModCreativeTabs; +import net.minecraft.client.Minecraft; +import net.minecraft.world.item.CreativeModeTab; + +/** + * Makes the client show the content it now holds: after the server's pack list was applied, which + * also happens after {@code /mcheli reload}. + */ +final class ClientContentReload { + private ClientContentReload() { + } + + /** Drops cached models and textures so edited and new files load, and rebuilds the creative tabs. */ + static void refresh() { + Minecraft mc = Minecraft.getInstance(); + MeshCache.INSTANCE.clear(); + RuntimeAssets.refreshTextures(); + if (mc.level != null && mc.player != null) { + boolean operator = mc.options.operatorItemsTab().get() && mc.player.canUseGameMasterBlocks(); + CreativeModeTab.ItemDisplayParameters params = + new CreativeModeTab.ItemDisplayParameters(mc.level.enabledFeatures(), operator, mc.level.registryAccess()); + ModCreativeTabs.all().forEach(tab -> tab.buildContents(params)); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 0000000..50b9bed --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,215 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.hud.HudRenderer; +import com.jnhprojects.mcheli.client.hud.HudScripts; +import com.jnhprojects.mcheli.client.render.A10Renderer; +import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.PreparableReloadListener; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; + +import java.util.List; +import java.util.Map; + +/** + * Client side counterpart of {@code CommonHooks}: what the mod does on client events. The loader + * module calls these from its own events. Kept out of the shared entry point so dedicated servers + * never load these classes. + */ +public final class ClientHooks { + /** The weapon HUD's layer, drawn above the vanilla HUD. */ + public static final Identifier HUD_LAYER = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "weapons"); + + private ClientHooks() { + } + + /** During mod construction, on a client only. */ + public static void init() { + ClientConfig.load(Platform.get().configDir()); + ClientAccess.install(() -> Minecraft.getInstance().player, ClientPacks.INSTANCE); + } + + /** How a loader registers an entity renderer. */ + public interface EntityRenderers { + void register(EntityType type, EntityRendererProvider provider); + } + + public static void registerEntityRenderers(EntityRenderers renderers) { + renderers.register(ModEntities.HELICOPTER.get(), AircraftRenderer::new); + renderers.register(ModEntities.PLANE.get(), AircraftRenderer::new); + renderers.register(ModEntities.TANK.get(), AircraftRenderer::new); + renderers.register(ModEntities.VEHICLE.get(), AircraftRenderer::new); + renderers.register(ModEntities.BULLET.get(), ProjectileRenderer::new); + renderers.register(ModEntities.ROCKET.get(), ProjectileRenderer::new); + renderers.register(ModEntities.BOMB.get(), ProjectileRenderer::new); + renderers.register(ModEntities.AA_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.AS_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.AT_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.TV_MISSILE.get(), ProjectileRenderer::new); + renderers.register(ModEntities.TORPEDO.get(), ProjectileRenderer::new); + renderers.register(ModEntities.MARKER_ROCKET.get(), ProjectileRenderer::new); + renderers.register(ModEntities.DISPENSED_ITEM.get(), ProjectileRenderer::new); + renderers.register(ModEntities.FLARE.get(), FlareRenderer::new); + renderers.register(ModEntities.A10.get(), A10Renderer::new); + } + + public static List keyMappings() { + return KeyBindings.ALL; + } + + /** Client resource reload listeners, by the id Fabric wants for each. */ + public static Map reloadListeners() { + return Map.of(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "meshes"), MeshCache.INSTANCE, + Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "hud_scripts"), HudScripts.INSTANCE); + } + + public static void clientTickEnd() { + CrewController.onClientTick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + } + + /** Tilts the view with the hull and moves the eye into the cockpit or onto a TV missile. */ + public static void computeCameraAngles(CameraAngles angles) { + CrewController.onCameraAngles(angles); + } + + /** Sets the camera's rotation, roll included, the way Forge's {@code Camera#setRotation} does. */ + public interface CameraRotation { + void set(Camera camera, float yaw, float pitch, float roll); + } + + /** + * For loaders without Forge's camera angle event: called where Forge fires it, right after + * vanilla placed the camera for this frame. Starts from the camera's own angles and no roll, + * and only touches the rotation when something changed it. + */ + public static void computeCameraAngles(Camera camera, float partialTick, CameraRotation rotation) { + Angles angles = new Angles(camera, partialTick, camera.yRot(), camera.xRot()); + computeCameraAngles(angles); + if (angles.yaw != camera.yRot() || angles.pitch != camera.xRot() || angles.roll != 0.0F) { + rotation.set(camera, angles.yaw, angles.pitch, angles.roll); + } + } + + private static final class Angles implements CameraAngles { + private final Camera camera; + private final float partialTick; + private float yaw; + private float pitch; + private float roll; + + Angles(Camera camera, float partialTick, float yaw, float pitch) { + this.camera = camera; + this.partialTick = partialTick; + this.yaw = yaw; + this.pitch = pitch; + } + + @Override + public Camera camera() { + return camera; + } + + @Override + public float partialTick() { + return partialTick; + } + + @Override + public float yaw() { + return yaw; + } + + @Override + public float pitch() { + return pitch; + } + + @Override + public float roll() { + return roll; + } + + @Override + public void setYaw(float yaw) { + this.yaw = yaw; + } + + @Override + public void setPitch(float pitch) { + this.pitch = pitch; + } + + @Override + public void setRoll(float roll) { + this.roll = roll; + } + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand(); + } + + /** What happens to a press of the use key. */ + public enum UseKey { + /** Vanilla handles it. */ + PASS, + /** Taken, and the hand does not swing: it is a weapon trigger. */ + CONSUME, + /** Taken, and the hand swings as for a vanilla click: the player boards a vehicle. */ + CONSUME_AND_SWING + } + + public static UseKey onUseKey(InteractionHand hand) { + if (WeaponController.onUseItem()) { + return UseKey.CONSUME; + } + return VehiclePicker.onUseItem(hand) ? UseKey.CONSUME_AND_SWING : UseKey.PASS; + } + + /** The pick block key switches weapons in a seat that has any; true when it was taken. */ + public static boolean onPickBlockKey() { + return WeaponController.onPickBlock(); + } + + public static void beforeRenderLiving(LivingEntityRenderState state, PoseStack pose, float partialTicks) { + RiderPose.beforeRender(state, pose, partialTicks); + } + + public static void drawHud(GuiGraphicsExtractor graphics, DeltaTracker delta) { + WeaponHud.draw(graphics, delta); + } + + /** Without the original zip there are no vehicles, so say so where the player will see it. */ + public static void loggedIn(LocalPlayer player) { + MCHeli.legacyContent().problem().ifPresent(player::sendSystemMessage); + } + + public static void loggedOut() { + ClientPacks.INSTANCE.loggedOut(); + } +} 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 new file mode 100644 index 0000000..13f2bad --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,325 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackRequestPacket; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import com.jnhprojects.mcheli.platform.Platform; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * The client half of the content pack sync (the server half is {@code PackSync}). + * + *

When the server sends its pack list, every pack this client has neither in its own + * {@code mcheli/packs} nor in {@code mcheli/cache} is requested. Each arrives in chunks, is checked + * against its hash and kept as {@code mcheli/cache/.zip}. Once all are there, the content is + * rebuilt from the client's own import plus exactly the server's packs, in the server's order, so + * the vehicle a player flies (which their client simulates) has the server's definition. + * + *

In single player, and for the host of a LAN game, client and server share one registry, so + * only the client's view of the server's packs is refreshed. + * + *

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(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + private final Path cache = Platform.get().gameDir().resolve(MCHeli.MOD_ID).resolve("cache"); + private List wanted = List.of(); + private final Map downloads = new LinkedHashMap<>(); + /** Packs this class opened for the current server; closed when the next list replaces them. */ + private List mounted = List.of(); + private int downloaded; + + private ClientPacks() { + } + + @Override + public void onPackList(PackListPacket packet) { + if (Minecraft.getInstance().hasSingleplayerServer()) { + useRoots(MCHeli.packs().active()); + ClientContentReload.refresh(); + return; + } + + wanted = packet.packs(); + downloads.clear(); + downloaded = 0; + long cap = ClientConfig.packCacheBytes(); + List request = new ArrayList<>(); + long bytes = 0; + for (PackListPacket.Entry entry : wanted) { + if (find(entry) != null) { + continue; + } + if (entry.size() > cap || entry.size() <= 0) { + say(Component.translatable("message.mcheli.pack.too_large", entry.id(), mb(entry.size()), cap >> 20)); + continue; + } + downloads.put(entry.hash(), new Download(entry)); + request.add(entry.hash()); + bytes += entry.size(); + } + + if (request.isEmpty()) { + apply(); + } else { + say(Component.translatable("message.mcheli.pack.downloading", request.size(), mb(bytes))); + ModNetwork.sendToServer(new PackRequestPacket(request)); + } + } + + @Override + public void onPackData(PackDataPacket packet) { + Download download = downloads.get(packet.hash()); + if (download == null) { + return; + } + if (!download.accept(packet)) { + fail(download, "the server sent a chunk out of order"); + return; + } + if (!download.complete()) { + return; + } + downloads.remove(packet.hash()); + try { + store(download); + downloaded++; + } catch (IOException | PackException e) { + fail(download, e.getMessage()); + return; + } + if (downloads.isEmpty()) { + apply(); + } + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + wanted = List.of(); + RuntimeAssets.setRoots(List.of()); + close(mounted); + mounted = List.of(); + } + + private void fail(Download download, String reason) { + downloads.remove(download.entry.hash()); + MCHeli.LOGGER.error("Content pack {} could not be downloaded: {}", download.entry.id(), reason); + say(Component.translatable("message.mcheli.pack.failed", download.entry.id(), reason)); + if (downloads.isEmpty()) { + apply(); + } + } + + /** Writes a finished download to the cache once its content matches the hash it was listed with. */ + private void store(Download download) throws IOException, PackException { + String hash = download.entry.hash(); + Files.createDirectories(cache); + Path part = cache.resolve(hash + ".download.zip"); + Files.write(part, download.data); + try (ContentPack pack = ContentPack.open(part)) { + if (!pack.hash().equals(hash)) { + throw new PackException("its files do not match the hash the server listed"); + } + } catch (IOException | PackException e) { + Files.deleteIfExists(part); + throw e; + } + Files.move(part, cache.resolve(hash + ".zip"), StandardCopyOption.REPLACE_EXISTING); + } + + /** Rebuilds the content from the import and the server's packs that are now on disk. */ + private void apply() { + List opened = new ArrayList<>(); + List sources = new ArrayList<>(); + sources.add(new ContentSource(ContentSource.ORIGINAL, MCHeli.legacyContent().assets())); + for (PackListPacket.Entry entry : wanted) { + Path file = find(entry); + if (file == null) { + continue; + } + try { + ContentPack pack = ContentPack.open(file); + if (!pack.hash().equals(entry.hash())) { + pack.close(); + MCHeli.LOGGER.warn("Content pack {} changed on disk and no longer matches the server's", file); + continue; + } + opened.add(pack); + sources.add(pack.contentSource()); + if (file.startsWith(cache)) { + Files.setLastModifiedTime(file, FileTime.from(Instant.now())); + } + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not open content pack {}", file, e); + } + } + + ContentRegistry registry = ContentRegistry.load(sources); + MCHeli.content().replaceWith(registry); + List old = mounted; + mounted = List.copyOf(opened); + useRoots(mounted); + close(old); + ClientContentReload.refresh(); + trimCache(); + if (downloaded > 0) { + say(Component.translatable("message.mcheli.pack.ready", downloaded)); + downloaded = 0; + } + } + + /** The pack on disk with the entry's content: one of the player's own packs, or a cached download. */ + private @Nullable Path find(PackListPacket.Entry entry) { + for (ContentPack own : MCHeli.packs().active()) { + if (own.hash().equals(entry.hash())) { + return own.source(); + } + } + Path cached = cache.resolve(entry.hash() + ".zip"); + return Files.isRegularFile(cached) ? cached : null; + } + + /** Deletes the cached packs used longest ago until the cache fits its cap. */ + private void trimCache() { + Set keep = new HashSet<>(); + for (PackListPacket.Entry entry : wanted) { + keep.add(entry.hash() + ".zip"); + } + List files; + try (Stream list = Files.list(cache)) { + files = list.filter(p -> CACHED.matcher(p.getFileName().toString()).matches()) + .sorted(Comparator.comparing(ClientPacks::lastUsed)) + .toList(); + } catch (IOException e) { + return; + } + long total = 0; + for (Path file : files) { + total += size(file); + } + long cap = ClientConfig.packCacheBytes(); + for (Path file : files) { + if (total <= cap) { + break; + } + if (keep.contains(file.getFileName().toString())) { + continue; + } + try { + long size = size(file); + Files.delete(file); + total -= size; + MCHeli.LOGGER.info("Removed {} from the content pack cache", file.getFileName()); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not remove {} from the content pack cache", file, e); + } + } + } + + private static FileTime lastUsed(Path file) { + try { + return Files.getLastModifiedTime(file); + } catch (IOException e) { + return FileTime.fromMillis(0); + } + } + + private static long size(Path file) { + try { + return Files.size(file); + } catch (IOException e) { + return 0; + } + } + + /** The pack loaded last overrides the others, so it is searched first. */ + private static void useRoots(List packs) { + List roots = new ArrayList<>(); + for (ContentPack pack : packs) { + roots.addFirst(pack.assets()); + } + RuntimeAssets.setRoots(roots); + } + + private static void close(List packs) { + for (ContentPack pack : packs) { + try { + pack.close(); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not close content pack {}", pack, e); + } + } + } + + private static void say(Component message) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player != null) { + mc.player.sendSystemMessage(message); + } else { + MCHeli.LOGGER.info(message.getString()); + } + } + + private static String mb(long bytes) { + return "%.1f".formatted(bytes / 1048576.0); + } + + /** One pack arriving from the server. */ + private static final class Download { + private final PackListPacket.Entry entry; + private byte @Nullable [] data; + private int received; + + Download(PackListPacket.Entry entry) { + this.entry = entry; + } + + boolean accept(PackDataPacket packet) { + if (packet.total() != entry.size() || packet.offset() != received + || received + packet.data().length > entry.size()) { + return false; + } + if (data == null) { + data = new byte[entry.size()]; + } + System.arraycopy(packet.data(), 0, data, received, packet.data().length); + received += packet.data().length; + return true; + } + + boolean complete() { + return data != null && received == data.length; + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java similarity index 94% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java index 25c6bfb..f1cce61 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -24,15 +24,12 @@ import net.minecraft.util.Mth; import net.minecraft.world.entity.player.Input; import net.minecraft.world.phys.Vec3; -import net.minecraftforge.client.event.RenderHandEvent; -import net.minecraftforge.client.event.ViewportEvent; -import net.minecraftforge.event.TickEvent; -import net.minecraftforge.fml.util.ObfuscationReflectionHelper; import org.joml.Vector3f; import org.jspecify.annotations.Nullable; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; /** * Turns local player input into vehicle control, for whichever seat the player occupies. @@ -68,17 +65,11 @@ final class CrewController { private CrewController() { } - static void init() { - TickEvent.ClientTickEvent.Post.BUS.addListener(event -> onClientTick()); - ViewportEvent.ComputeCameraAngles.BUS.addListener(CrewController::onCameraAngles); - RenderHandEvent.BUS.addListener(CrewController::onRenderHand); - } - /** * Like the original, the first person hand stays hidden in a vehicle: always for the pilot, * and for anyone else who looks through a camera or has a weapon to aim. */ - private static boolean onRenderHand(RenderHandEvent event) { + static boolean hidesHand() { LocalPlayer player = Minecraft.getInstance().player; AircraftEntity vehicle = vehicleOf(player); if (vehicle == null) { @@ -98,7 +89,7 @@ private static boolean onRenderHand(RenderHandEvent event) { return player != null && player.getVehicle() instanceof AircraftEntity vehicle && vehicle.definition() != null ? vehicle : null; } - private static void onClientTick() { + static void onClientTick() { ticks++; Minecraft mc = Minecraft.getInstance(); LocalPlayer player = mc.player; @@ -245,7 +236,7 @@ private static boolean isFreeLook(AircraftEntity vehicle) { return held; } - private static void onCameraAngles(ViewportEvent.ComputeCameraAngles event) { + static void onCameraAngles(CameraAngles event) { Minecraft mc = Minecraft.getInstance(); LocalPlayer player = mc.player; AircraftEntity vehicle = vehicleOf(player); @@ -257,7 +248,7 @@ private static void onCameraAngles(ViewportEvent.ComputeCameraAngles event) { if (seat < 0 || seat >= def.seatCount()) { return; } - float partialTicks = event.getPartialTick(); + float partialTicks = event.partialTick(); boolean firstPerson = mc.options.getCameraType().isFirstPerson(); if (seat == 0 && vehicle.steersWithMouse() && !vehicle.isGunnerMode() && vehicle.getControllingPassenger() == player) { @@ -271,13 +262,13 @@ private static void onCameraAngles(ViewportEvent.ComputeCameraAngles event) { if (!(firstPerson && missile != null)) { // Every seat of every kind tilts with the hull, measured along the way the rider looks. event.setRoll(AircraftRotation.cameraRoll(vehicle.getYRot(partialTicks), vehicle.getXRot(partialTicks), - vehicle.roll(partialTicks), event.getYaw(), event.getPitch())); + vehicle.roll(partialTicks), event.yaw(), event.pitch())); } if (firstPerson && missile != null) { - placeCamera(event.getCamera(), missile.getPosition(partialTicks)); + placeCamera(event.camera(), missile.getPosition(partialTicks)); } else if (firstPerson) { Vector3 eye = eyePosition(vehicle, def, seat); - moveCamera(event.getCamera(), vehicle, def, eye != null ? eye : seatEye(vehicle, def, seat, player), seat, partialTicks); + moveCamera(event.camera(), vehicle, def, eye != null ? eye : seatEye(vehicle, def, seat, player), seat, partialTicks); } } @@ -293,7 +284,7 @@ private static Vector3 seatEye(AircraftEntity vehicle, AircraftDefinition def, i } /** The phase one pilot model: mouse movement becomes aircraft rotation, the view stays on the nose. */ - private static void steerWithMouse(AircraftEntity vehicle, LocalPlayer player, ViewportEvent.ComputeCameraAngles event, + private static void steerWithMouse(AircraftEntity vehicle, LocalPlayer player, CameraAngles event, float partialTicks, boolean firstPerson) { boolean freeLook = KeyBindings.FREE_LOOK.isDown(); boolean ownPitch = vehicle.isHovering(); @@ -432,12 +423,17 @@ private static void placeCamera(Camera camera, Vec3 at) { } } + private static Method accessible(Method method) { + method.setAccessible(true); + return method; + } + /** {@code Camera.setPosition} is protected. Looked up once; on failure the feature turns itself off. */ private static @Nullable MethodHandle cameraSetter() { if (setCameraPosition == null && !cameraLookupFailed) { try { setCameraPosition = MethodHandles.lookup().unreflect( - ObfuscationReflectionHelper.findMethod(Camera.class, "setPosition", Vec3.class)); + accessible(Camera.class.getDeclaredMethod("setPosition", Vec3.class))); } catch (Exception e) { MCHeli.LOGGER.warn("Camera.setPosition is not accessible; the first person eye stays where vanilla puts it", e); cameraLookupFailed = true; diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java new file mode 100644 index 0000000..9507412 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java @@ -0,0 +1,68 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.BoardVehiclePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +/** + * Right-clicking anywhere on a vehicle's hull boards it. + * + *

Vanilla only picks an entity by its main hit box, which for most MCHeli vehicles is the + * default two by 0.7 block box at their feet; the hull's real shape lives in its extra + * {@code BoundingBox} lines. In the original those boxes were clickable too. Inflating the main box + * instead would change how the vehicle collides with terrain, so the use key looks for them here. + */ +final class VehiclePicker { + private VehiclePicker() { + } + + static boolean onUseItem(InteractionHand hand) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (player == null || hand != InteractionHand.MAIN_HAND || player.getVehicle() instanceof AircraftEntity) { + return false; + } + // Vanilla already handles a click on the main box. + if (mc.hitResult instanceof EntityHitResult hit && hit.getEntity() instanceof AircraftEntity) { + return false; + } + Hit hit = pick(player, mc.hitResult); + if (hit == null) { + return false; + } + ModNetwork.sendToServer(new BoardVehiclePacket(hit.vehicle.getId(), hit.location)); + return true; + } + + private record Hit(AircraftEntity vehicle, Vec3 location) { + } + + /** The nearest vehicle hull along the view, closer than the block or entity vanilla picked. */ + private static @Nullable Hit pick(Player player, @Nullable HitResult vanilla) { + double range = player.entityInteractionRange(); + Vec3 eye = player.getEyePosition(); + Vec3 to = eye.add(player.getViewVector(1.0F).scale(range)); + double limit = vanilla != null && vanilla.getType() != HitResult.Type.MISS ? eye.distanceToSqr(vanilla.getLocation()) : range * range; + Hit best = null; + for (AircraftEntity vehicle : player.level().getEntitiesOfClass(AircraftEntity.class, new AABB(eye, to).inflate(16.0))) { + Optional at = vehicle.clipHitBoxes(eye, to, 0.2); + vehicle.consumeHitBoxFactor(); + if (at.isPresent() && eye.distanceToSqr(at.get()) < limit) { + limit = eye.distanceToSqr(at.get()); + best = new Hit(vehicle, at.get()); + } + } + return best; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java similarity index 90% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java index 904d871..1bc2ec0 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -23,8 +23,6 @@ import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; import net.minecraft.world.phys.Vec3; -import net.minecraftforge.client.event.InputEvent; -import net.minecraftforge.event.TickEvent; import org.jspecify.annotations.Nullable; /** @@ -38,12 +36,6 @@ final class WeaponController { private WeaponController() { } - static void init() { - TickEvent.ClientTickEvent.Post.BUS.addListener(event -> onClientTick()); - InputEvent.InteractionKeyMappingTriggered.UseItem.BUS.addListener(WeaponController::onUseItem); - InputEvent.InteractionKeyMappingTriggered.PickBlock.BUS.addListener(WeaponController::onPickBlock); - } - private record Seat(AircraftEntity vehicle, VehicleWeapons weapons, int seat) { } @@ -60,16 +52,12 @@ private record Seat(AircraftEntity vehicle, VehicleWeapons weapons, int seat) { } /** The use key is the trigger while the seat has a weapon, so it must not also use items. */ - private static boolean onUseItem(InputEvent.InteractionKeyMappingTriggered.UseItem event) { + static boolean onUseItem() { Seat s = seat(Minecraft.getInstance().player); - if (s != null && s.weapons.selected(s.seat) >= 0) { - event.setSwingHand(false); - return true; - } - return false; + return s != null && s.weapons.selected(s.seat) >= 0; } - private static boolean onPickBlock(InputEvent.InteractionKeyMappingTriggered.PickBlock event) { + static boolean onPickBlock() { Seat s = seat(Minecraft.getInstance().player); if (s == null || s.weapons.sets().isEmpty()) { return false; @@ -80,7 +68,7 @@ private static boolean onPickBlock(InputEvent.InteractionKeyMappingTriggered.Pic return true; } - private static void onClientTick() { + static void onClientTick() { Minecraft mc = Minecraft.getInstance(); LocalPlayer player = mc.player; Seat s = seat(player); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java similarity index 91% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java index 68db92e..3b98002 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java @@ -1,6 +1,5 @@ package com.jnhprojects.mcheli.client; -import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.client.hud.HudRenderer; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.entity.AircraftEntity; @@ -13,9 +12,6 @@ import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.player.LocalPlayer; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import net.minecraftforge.client.event.AddGuiOverlayLayersEvent; -import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; /** * The vehicle HUD layer. In first person it runs the seat's HUD script (the {@code HUD} line of @@ -34,17 +30,12 @@ final class WeaponHud { private WeaponHud() { } - static void init() { - AddGuiOverlayLayersEvent.BUS.addListener(event -> event.getLayeredDraw() - .add(ForgeLayeredDraw.POST_SLEEP_STACK, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "weapons"), WeaponHud::draw)); - } - /** A pilot in gunner mode gets the gunner seat's HUD, as in the original. */ private static int hudSeat(AircraftEntity vehicle, int seat) { return seat == 0 && vehicle.isGunnerMode() ? 1 : seat; } - private static void draw(GuiGraphicsExtractor g, DeltaTracker delta) { + static void draw(GuiGraphicsExtractor g, DeltaTracker delta) { Minecraft mc = Minecraft.getInstance(); LocalPlayer player = mc.player; if (player == null || !(player.getVehicle() instanceof AircraftEntity vehicle)) { diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java similarity index 97% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java index 0de1a15..0115b4c 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java @@ -13,7 +13,6 @@ import net.minecraft.client.player.LocalPlayer; import net.minecraft.resources.Identifier; import net.minecraft.util.Mth; -import net.minecraftforge.event.TickEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,11 +57,9 @@ private HudRenderer(HudCanvas canvas, AircraftEntity vehicle, AircraftDefinition this.centerY = height / 2.0; } - public static void init() { - TickEvent.ClientTickEvent.Post.BUS.addListener(event -> { - LocalPlayer player = Minecraft.getInstance().player; - STATE.tick(player != null && player.getVehicle() instanceof AircraftEntity vehicle ? vehicle : null); - }); + public static void tick() { + LocalPlayer player = Minecraft.getInstance().player; + STATE.tick(player != null && player.getVehicle() instanceof AircraftEntity vehicle ? vehicle : null); } /** Fed by the pilot's mouse steering; moves the control stick indicator. */ diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java similarity index 98% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java index b701fed..d63fc2a 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -1,6 +1,5 @@ package com.jnhprojects.mcheli.client.hud; -import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import com.jnhprojects.mcheli.entity.AircraftEntity; @@ -10,6 +9,7 @@ import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; import com.jnhprojects.mcheli.hud.HudVariable; import com.jnhprojects.mcheli.hud.TextArg; +import com.jnhprojects.mcheli.platform.Platform; import com.jnhprojects.mcheli.weapon.Guidance; import com.jnhprojects.mcheli.weapon.VehicleWeapons; import com.jnhprojects.mcheli.weapon.WeaponSet; @@ -19,7 +19,6 @@ import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.monster.Enemy; import net.minecraft.world.level.Level; -import net.minecraftforge.fml.ModList; import org.jspecify.annotations.Nullable; import java.util.ArrayList; @@ -320,8 +319,7 @@ boolean hasMagazine() { case RELOAD_SEC -> reloadSeconds; case MORTAR_DIST -> -1.0F; case MC_VER -> "26.3"; - case MOD_VER -> ModList.getModContainerById(MCHeli.MOD_ID) - .map(c -> c.getModInfo().getVersion().toString()).orElse(""); + case MOD_VER -> Platform.get().modVersion(); case MOD_NAME -> "MCHeli Rework"; case YAW -> rotate360(vehicle.getYRot() + 180.0); case PITCH -> -vehicle.getXRot(); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java diff --git a/forge-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 similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java new file mode 100644 index 0000000..47cc66e --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java @@ -0,0 +1,88 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import org.joml.Matrix4f; +import org.joml.Vector3f; + +import java.util.List; + +/** Streams {@link MeshGroup} triangles into an entity render type. */ +final class MeshRenderer { + private static final int WHITE = 0xFFFFFFFF; + + private MeshRenderer() { + } + + /** + * Entity render types draw quads, so each triangle is sent as a quad whose last corner repeats + * the third one. The GPU discards the degenerate half for free. + */ + static void emit(List groups, PoseStack.Pose pose, VertexConsumer buffer, int light, int overlay) { + emit(groups, pose, buffer, light, overlay, WHITE); + } + + /** As above, tinted with an ARGB colour, for bullets drawn in their {@code BulletColor}. */ + static void emit(List groups, PoseStack.Pose pose, VertexConsumer buffer, int light, int overlay, int color) { + for (MeshGroup group : groups) { + float[] p = group.positions(); + float[] n = group.normals(); + float[] uv = group.uvs(); + int vertices = group.vertexCount(); + for (int v = 0; v < vertices; v += 3) { + vertex(buffer, pose, p, n, uv, v, light, overlay, color); + vertex(buffer, pose, p, n, uv, v + 1, light, overlay, color); + vertex(buffer, pose, p, n, uv, v + 2, light, overlay, color); + vertex(buffer, pose, p, n, uv, v + 2, light, overlay, color); + } + } + } + + /** + * As above, but only the triangles that face the camera. No vanilla type is both culled and + * free of depth writes, so glass is culled here. Back to back glass faces would otherwise sort + * in an arbitrary order and swap places as the view moves. + * + *

Entities are submitted in world-aligned space with the camera at the origin, so a front + * face (counter-clockwise, as in the original's GL setup) is one whose normal points back + * towards the origin. + */ + static void emitFrontFaces(List groups, PoseStack.Pose pose, VertexConsumer buffer, int light, int overlay, int color) { + Matrix4f m = pose.pose(); + Vector3f a = new Vector3f(); + Vector3f b = new Vector3f(); + Vector3f c = new Vector3f(); + for (MeshGroup group : groups) { + float[] p = group.positions(); + float[] n = group.normals(); + float[] uv = group.uvs(); + int vertices = group.vertexCount(); + for (int v = 0; v < vertices; v += 3) { + m.transformPosition(p[v * 3], p[v * 3 + 1], p[v * 3 + 2], a); + m.transformPosition(p[v * 3 + 3], p[v * 3 + 4], p[v * 3 + 5], b); + m.transformPosition(p[v * 3 + 6], p[v * 3 + 7], p[v * 3 + 8], c); + b.sub(a); + c.sub(a); + if (b.cross(c).dot(a) >= 0.0F) { + continue; + } + vertex(buffer, pose, p, n, uv, v, light, overlay, color); + vertex(buffer, pose, p, n, uv, v + 1, light, overlay, color); + vertex(buffer, pose, p, n, uv, v + 2, light, overlay, color); + vertex(buffer, pose, p, n, uv, v + 2, light, overlay, color); + } + } + } + + private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float[] p, float[] n, float[] uv, int i, int light, int overlay, int color) { + int a = i * 3; + int t = i * 2; + buffer.addVertex(pose, p[a], p[a + 1], p[a + 2]) + .setColor(color) + .setUv(uv[t], uv[t + 1]) + .setOverlay(overlay) + .setLight(light) + .setNormal(pose, n[a], n[a + 1], n[a + 2]); + } +} diff --git a/forge-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 similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java similarity index 79% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java index c297d9d..9ddb169 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -2,12 +2,13 @@ import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.physics.AircraftRotation; +import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.Vec3; -import net.minecraftforge.client.event.RenderLivingEvent; import org.joml.Quaternionf; /** @@ -24,13 +25,10 @@ public final class RiderPose { private RiderPose() { } - public static void init() { - RenderLivingEvent.Pre.BUS.addListener(RiderPose::onRenderLiving); - } - - private static void onRenderLiving(RenderLivingEvent.Pre event) { + /** Called before a living entity is drawn, with the pose the renderer is about to use. */ + public static void beforeRender(LivingEntityRenderState renderState, PoseStack pose, float partialTicks) { // Render states carry no entity reference; players are the ones that keep their id. - if (!(event.getState() instanceof AvatarRenderState state)) { + if (!(renderState instanceof AvatarRenderState state)) { return; } ClientLevel level = Minecraft.getInstance().level; @@ -38,7 +36,6 @@ private static void onRenderLiving(RenderLivingEvent.Pre event) { if (rider == null || !(rider.getVehicle() instanceof AircraftEntity vehicle)) { return; } - float partialTicks = event.getCameraState().cameraEntityPartialTicks; float yaw = vehicle.getYRot(partialTicks); float pitch = vehicle.getXRot(partialTicks); float roll = vehicle.roll(partialTicks); @@ -47,6 +44,6 @@ private static void onRenderLiving(RenderLivingEvent.Pre event) { } Quaternionf tilt = AircraftRotation.orientation(yaw, pitch, roll).rotateY((float) Math.toRadians(yaw)); Vec3 hip = rider.getVehicleAttachmentPoint(vehicle); - event.getPoseStack().rotateAround(tilt, (float) hip.x, (float) hip.y, (float) hip.z); + pose.rotateAround(tilt, (float) hip.x, (float) hip.y, (float) hip.z); } } 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 new file mode 100644 index 0000000..8f91e64 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -0,0 +1,89 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** + * The looping engine sound of one vehicle. 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. + */ +final class EngineSound extends RuntimeSoundInstance { + private static final float MAX_DOPPLER = 0.2F; + + private final AircraftEntity vehicle; + private final EngineSoundSource source; + private float doppler; + private double lastDistance = -1.0; + + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.vehicle = vehicle; + this.source = source; + this.looping = true; + this.delay = 0; + update(); + } + + @Override + public void tick() { + if (vehicle.isRemoved() || vehicle.definition() == null) { + stop(); + return; + } + update(); + if (volume <= 0.0F) { + stop(); + } + } + + private void update() { + AircraftDefinition def = vehicle.definition(); + LocalPlayer player = Minecraft.getInstance().player; + if (def == null || player == null) { + volume = 0.0F; + return; + } + volume = def.soundVolume * source.engineVolume(); + float pitch = def.soundPitch * source.enginePitch(); + boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); + if (aboard) { + x = vehicle.getX(); + y = vehicle.getY(); + z = vehicle.getZ(); + } else { + double dx = vehicle.getX() - player.getX(); + double dz = vehicle.getZ() - player.getZ(); + double distance = Math.sqrt(dx * dx + dz * dz); + if (lastDistance >= 0.0) { + double closing = lastDistance - distance; + if (Math.abs(closing) > 0.3) { + doppler = Math.clamp(doppler + (float) (closing / 40.0), -MAX_DOPPLER, MAX_DOPPLER); + } + } + doppler *= 0.9F; + pitch += doppler; + lastDistance = distance; + + double scale = def.soundRange / 16.0; + x = player.getX() + dx / scale; + y = player.getY() + (vehicle.getY() - player.getY()) / scale; + z = player.getZ() + dz / scale; + } + this.pitch = Math.max(pitch, 0.0F); + } + + @Override + public boolean canStartSilent() { + return true; + } +} 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 new file mode 100644 index 0000000..b0d46e3 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.entity.Entity; + +import java.util.HashMap; +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. + */ +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<>(); + + private EngineSoundPlayer() { + } + + public static void tick() { + Minecraft mc = Minecraft.getInstance(); + ClientLevel level = mc.level; + LocalPlayer player = mc.player; + if (level == null || player == null || mc.isPaused()) { + if (level == null) { + PLAYING.clear(); + COOLDOWN.clear(); + } + return; + } + PLAYING.values().removeIf(EngineSound::isStopped); + COOLDOWN.replaceAll((id, ticks) -> ticks - 1); + COOLDOWN.values().removeIf(ticks -> ticks <= 0); + + for (Entity entity : level.entitiesForRendering()) { + 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)) { + continue; + } + String sound = def.soundOrDefault(); + double range = def.soundRange * 1.2; + if (sound.isEmpty() || 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); + } + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java similarity index 80% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java index 64f39e7..6c87314 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -9,6 +9,7 @@ import net.minecraft.client.sounds.SoundBufferLibrary; import net.minecraft.client.sounds.SoundManager; import net.minecraft.client.sounds.WeighedSoundEvents; +import net.minecraft.resources.Identifier; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundSource; import net.minecraft.util.RandomSource; @@ -30,7 +31,7 @@ * Content added while the game runs can bring sounds this way without touching * {@code sounds.json} or reloading resources. */ -abstract class RuntimeSoundInstance extends AbstractTickableSoundInstance { +public abstract class RuntimeSoundInstance extends AbstractTickableSoundInstance { private @Nullable Path file; protected RuntimeSoundInstance(SoundEvent event, SoundSource source, RandomSource random) { @@ -53,11 +54,19 @@ protected RuntimeSoundInstance(SoundEvent event, SoundSource source, RandomSourc return soundEvent; } - @Override + /** + * Forge and NeoForge call this through their sound instance extension, which it overrides + * without saying so: Fabric has no such method and calls {@link #openStream} from a mixin. + */ public CompletableFuture getStream(SoundBufferLibrary buffers, Sound sound, boolean looping) { + return openStream(buffers, sound.getPath(), looping); + } + + /** Streams the file found on disk, or the resource {@code path} when there is none. */ + public CompletableFuture openStream(SoundBufferLibrary buffers, Identifier path, boolean looping) { Path source = file; if (source == null) { - return buffers.getStream(sound.getPath(), looping); + return buffers.getStream(path, looping); } return CompletableFuture.supplyAsync(() -> { try { diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java new file mode 100644 index 0000000..eda5ed7 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java @@ -0,0 +1,202 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.RackDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.RotatingPart; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; + +/** + * Purely visual state that every side advances once per tick: wheels, tracks, spinning parts and + * where each crew member is looking relative to the hull. + * + *

Ported from {@code updatePartWheel}, {@code updatePartCrawlerTrack} and + * {@code onUpdate_PartRotation}. Nothing in here affects physics, so it is never synced; each + * client derives it from the synced throttle, input and crew rotations. + */ +public final class AnimationState { + /** Road wheel roll in degrees. */ + public float wheelRoll; + public float prevWheelRoll; + /** Steering from -1 (full right) to 1 (full left). */ + public float steer; + public float prevSteer; + /** Track roller angle per side, index 1 is positive X. */ + public final float[] roller = new float[2]; + public final float[] prevRoller = new float[2]; + /** Track run per side, in link spacings from 0 to 1. */ + public final float[] track = new float[2]; + public final float[] prevTrack = new float[2]; + private final float[] trackSpeed = new float[2]; + public float[] rotatingParts = new float[0]; + public float[] prevRotatingParts = new float[0]; + public float throttle; + public float prevThrottle; + /** Per seat and rack: view yaw and pitch of the occupant relative to the hull. */ + public float[] seatYaw = new float[0]; + public float[] prevSeatYaw = new float[0]; + public float[] seatPitch = new float[0]; + public float[] prevSeatPitch = new float[0]; + + void resize(AircraftDefinition def) { + rotatingParts = new float[def.rotatingParts.size()]; + prevRotatingParts = new float[def.rotatingParts.size()]; + int seats = def.seatAndRackCount(); + seatYaw = new float[seats]; + prevSeatYaw = new float[seats]; + seatPitch = new float[seats]; + prevSeatPitch = new float[seats]; + for (int i = def.seatCount(); i < seats; i++) { + RackDefinition rack = def.rack(i); + seatYaw[i] = prevSeatYaw[i] = rack.yaw(); + seatPitch[i] = prevSeatPitch[i] = rack.pitch(); + } + } + + void tick(AircraftEntity vehicle, AircraftDefinition def) { + AircraftEntity.ControlInput input = vehicle.input(); + float visibleThrottle = vehicle.visibleThrottle(); + float reverse = vehicle.visibleReverse(); + prevThrottle = throttle; + throttle = visibleThrottle; + + tickWheels(def, input, visibleThrottle, reverse); + tickTracks(def, input, visibleThrottle, reverse); + tickRotatingParts(def, vehicle.getControllingPassenger() != null); + tickCrewAim(vehicle, def); + } + + private void tickWheels(AircraftDefinition def, AircraftEntity.ControlInput input, double throttle, float reverse) { + prevWheelRoll = wheelRoll; + prevSteer = steer; + if (def.enableBack && reverse > 0.01F && throttle <= 0.0) { + throttle = -reverse * 15.0F; + } + if (input.left && !input.right) { + steer = Math.min(steer + 0.1F, 1.0F); + } else if (input.right && !input.left) { + steer = Math.max(steer - 0.1F, -1.0F); + } else { + steer *= 0.9F; + } + wheelRoll += (float) (throttle * def.partWheelRot); + if (wheelRoll >= 360.0F) { + wheelRoll -= 360.0F; + prevWheelRoll -= 360.0F; + } else if (wheelRoll < 0.0F) { + wheelRoll += 360.0F; + prevWheelRoll += 360.0F; + } + } + + private void tickTracks(AircraftDefinition def, AircraftEntity.ControlInput input, double throttle, float reverse) { + System.arraycopy(roller, 0, prevRoller, 0, 2); + System.arraycopy(track, 0, prevTrack, 0, 2); + double pivot = def.pivotTurnThrottle <= 0.0F ? 1.0 : def.pivotTurnThrottle * 0.1F; + boolean left = input.left; + boolean right = input.right; + int dir = 1; + if (def.enableBack && reverse > 0.0F && throttle <= 0.0) { + throttle = -reverse * 5.0F; + // Reversing swaps which track leads a turn. + if (left != right) { + boolean t = left; + left = right; + right = t; + dir = -1; + } + } + if (left && !right) { + double t = 0.2 * dir; + trackSpeed[0] += (float) t; + trackSpeed[1] -= (float) (pivot * t); + } else if (right && !left) { + double t = 0.2 * dir; + trackSpeed[0] -= (float) (pivot * t); + trackSpeed[1] += (float) t; + } else { + float t = (float) Mth.clamp(throttle, -0.2, 0.2); + trackSpeed[0] += t; + trackSpeed[1] += t; + } + + for (int i = 0; i < 2; i++) { + trackSpeed[i] = Mth.clamp(trackSpeed[i], -0.72F, 0.72F); + roller[i] += trackSpeed[i] * def.trackRollerRot; + if (roller[i] >= 360.0F) { + roller[i] -= 360.0F; + prevRoller[i] -= 360.0F; + } else if (roller[i] < 0.0F) { + roller[i] += 360.0F; + prevRoller[i] += 360.0F; + } + track[i] -= trackSpeed[i]; + while (track[i] >= 1.0F) { + track[i]--; + prevTrack[i]--; + } + while (track[i] < 0.0F) { + track[i]++; + } + while (prevTrack[i] < 0.0F) { + prevTrack[i]++; + } + trackSpeed[i] *= 0.75F; + } + } + + private void tickRotatingParts(AircraftDefinition def, boolean piloted) { + for (int i = 0; i < rotatingParts.length && i < def.rotatingParts.size(); i++) { + RotatingPart part = def.rotatingParts.get(i); + prevRotatingParts[i] = rotatingParts[i]; + if (part.always() || piloted) { + float r = rotatingParts[i] + part.speed(); + if (r < 0.0F) { + r += 360.0F; + prevRotatingParts[i] += 360.0F; + } else if (r >= 360.0F) { + r -= 360.0F; + prevRotatingParts[i] -= 360.0F; + } + rotatingParts[i] = r; + } + } + } + + private void tickCrewAim(AircraftEntity vehicle, AircraftDefinition def) { + float hullYaw = vehicle.getYRot(); + float hullPitch = vehicle.getXRot(); + float hullRoll = vehicle.roll(); + for (int i = 0; i < seatYaw.length && i < def.seatCount(); i++) { + prevSeatYaw[i] = seatYaw[i]; + prevSeatPitch[i] = seatPitch[i]; + Entity crew = vehicle.occupant(i); + if (crew == null) { + continue; + } + float yaw = Mth.wrapDegrees(crew.getYRot() - hullYaw); + double rad = Math.toRadians(yaw); + // The hull tilt along the crew member's view direction, as in the original tank code. + float tilt = (float) (hullPitch * Math.cos(rad) + hullRoll * Math.sin(rad)); + float pitch = crew.getXRot() - tilt; + // Keep the previous value on the same side of the seam so interpolation never spins. + prevSeatYaw[i] = yaw + Mth.wrapDegrees(prevSeatYaw[i] - yaw); + seatYaw[i] = yaw; + seatPitch[i] = pitch; + } + } + + public float seatYaw(int seat, float partialTicks) { + if (seat < 0 || seat >= seatYaw.length) { + return 0.0F; + } + return prevSeatYaw[seat] + (seatYaw[seat] - prevSeatYaw[seat]) * partialTicks; + } + + public float seatPitch(int seat, float partialTicks) { + if (seat < 0 || seat >= seatPitch.length) { + return 0.0F; + } + return prevSeatPitch[seat] + (seatPitch[seat] - prevSeatPitch[seat]) * partialTicks; + } +} 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 new file mode 100644 index 0000000..bf89ea5 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * The few client facts common code needs, such as who the local player is. The client installs + * the real supplier at startup; a dedicated server keeps the empty default and never loads a + * client class through here. + */ +public final class ClientAccess { + private static Supplier<@Nullable Entity> localPlayer = () -> null; + private static PackReceiver packs = new PackReceiver() { + @Override + public void onPackList(PackListPacket packet) { + } + + @Override + public void onPackData(PackDataPacket packet) { + } + }; + + private ClientAccess() { + } + + public static void install(Supplier<@Nullable Entity> player, PackReceiver receiver) { + localPlayer = player; + packs = receiver; + } + + public static @Nullable Entity localPlayer() { + return localPlayer.get(); + } + + /** Where the server's content pack packets go. Called on the client's main thread. */ + public static PackReceiver packs() { + return packs; + } + + /** The client half of the content pack sync. */ + public interface PackReceiver { + void onPackList(PackListPacket packet); + + void onPackData(PackDataPacket packet); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java new file mode 100644 index 0000000..6b25a38 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.entity; + +/** + * A vehicle with an engine loop. The client sound player polls these values every tick; the + * definition's {@code SoundVolume} and {@code SoundPitch} are applied on top. + */ +public interface EngineSoundSource { + /** Loudness before the definition's multiplier. Zero silences the loop. */ + float engineVolume(); + + /** Pitch before the definition's multiplier. */ + float enginePitch(); +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java new file mode 100644 index 0000000..3029014 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java @@ -0,0 +1,130 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Static guns and launchers, ported from {@code MCH_EntityVehicle}. + * + *

The mount itself does not turn; the gunner looks around and the {@code AddPart} tree turns + * with the view. {@code CanMove} lets W/S push the mount and A/D turn it, for the few that set it. + */ +public class GroundVehicleEntity extends AircraftEntity implements EngineSoundSource { + private static final double MIN_SPEED = 0.07; + private double currentSpeed = MIN_SPEED; + + public GroundVehicleEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected @Nullable VehicleDefinition lookup(String name) { + return MCHeli.content().vehicle(name); + } + + @Override + public @Nullable VehicleDefinition definition() { + return (VehicleDefinition) super.definition(); + } + + /** + * {@code MCH_EntityVehicle.useCurrentWeapon}: the mount stays put while its {@code AddPart} + * tree turns with the gunner's view, so a weapon fires where the gunner looks. Only weapons + * with both yaw limits set aim like turrets instead. The original checked the first + * {@code AddWeapon} line of that weapon, and so does this. + */ + @Override + public boolean aimsWithUser(WeaponSet set) { + WeaponMount mount = set.mounts().getFirst(); + return mount.minYaw() == 0.0F || mount.maxYaw() == 0.0F; + } + + @Override + protected void simulate() { + VehicleDefinition def = definition(); + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + + if (getControllingPassenger() == null) { + throttle = Math.max(throttle - 0.00125, 0.0); + } else if (def.canMove) { + double x = 0.0; + double z = 0.0; + double yaw = Math.toRadians(getYRot()); + if (input.throttleUp) { + x += Math.sin(yaw); + z += Math.cos(yaw); + } + if (input.throttleDown) { + x -= Math.sin(yaw); + z -= Math.cos(yaw); + } + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + double d = Math.sqrt(x * x + z * z); + if (d > 1.0E-6) { + mx -= x / d * 0.03; + mz += z / d * 0.03; + } + } + if (isInWater()) { + setXRot(getXRot() * 0.9F); + } + + double prevMotion = Math.sqrt(mx * mx + mz * mz); + double depth = def.floats ? waterDepth() : 0.0; + if (depth == 0.0) { + my += isInWater() ? def.gravityInWater : def.gravity; + } else if (depth < 1.0) { + my -= 1.0E-4; + my += 0.007 * throttle; + } else { + if (my < 0.0) { + my /= 2.0; + } + my += 0.007; + } + + double motion = Math.sqrt(mx * mx + mz * mz); + float limit = def.speed; + if (motion > limit) { + mx *= limit / motion; + mz *= limit / motion; + motion = limit; + } + if (motion > prevMotion && currentSpeed < limit) { + currentSpeed = Math.min(limit, currentSpeed + (limit - currentSpeed) / 35.0); + } else { + currentSpeed = Math.max(MIN_SPEED, currentSpeed - (currentSpeed - MIN_SPEED) / 35.0); + } + if (onGround()) { + mx *= 0.5; + mz *= 0.5; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public float engineVolume() { + return visibleThrottle() * 2.0F; + } + + @Override + public float enginePitch() { + return visibleThrottle() * 0.5F; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java new file mode 100644 index 0000000..b140713 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -0,0 +1,318 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A helicopter, ported from {@code MCH_EntityHeli}. + * + *

The flight model is the original one: throttle produces lift that weakens as the aircraft + * tilts, and pitching the nose down converts throttle into forward speed through a cubic curve. + * Hover mode holds full throttle, levels the aircraft and lets W/A/S/D slide it around instead. + * + *

Two deliberate differences from 1.7.10: + *

    + *
  • The original ran roll damping once per rendered frame, so it depended on frame rate. It + * now runs once per tick with constants tuned to match the feel at 60 FPS.
  • + *
  • Blade folding, weapons, fuel and damage effects are not ported yet.
  • + *
+ */ +public class HelicopterEntity extends AircraftEntity implements EngineSoundSource { + /** + * Rotation per mouse count. The original used {@code 0.06 * partialTicks} with the partial tick + * low-pass filtered to roughly 0.5. + */ + private static final float LOOK_SCALE = 0.03F; + /** Vanilla turns the view 0.15 degrees per mouse count, so this recovers the raw count. */ + private static final float MOUSE_COUNTS_PER_DEGREE = 1.0F / 0.15F; + private static final float ROLL_DAMPING = 0.06F; + private static final float ROLL_DAMPING_HOVER = 0.1F; + private static final float ROLL_RATE = 1.8F; + private static final double MIN_SPEED = 0.07; + + private double currentSpeed = MIN_SPEED; + private float rotorAngle; + public float rotorAngleO; + + public HelicopterEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected @Nullable HelicopterDefinition lookup(String name) { + return MCHeli.content().helicopter(name); + } + + @Override + public @Nullable HelicopterDefinition definition() { + return (HelicopterDefinition) super.definition(); + } + + @Override + public boolean steersWithMouse() { + return true; + } + + @Override + public float engineVolume() { + HelicopterDefinition def = definition(); + return def != null && def.throttleUpDown <= 0.0F ? 0.0F : visibleThrottle() * 2.0F; + } + + @Override + public float enginePitch() { + return 0.2F + visibleThrottle() * 0.2F; + } + + /** Hovering or slow helicopters drop their gear sooner than ones passing over at speed. */ + @Override + protected float autoLowerGearThrottle() { + HelicopterDefinition def = definition(); + double dx = getX() - xo; + double dy = getY() - yo; + double dz = getZ() - zo; + float slow = def != null ? def.speed / 3.5F : 0.0F; + return dx * dx + dy * dy + dz * dz <= slow ? 0.8F : 0.3F; + } + + public float rotorAngle(float partialTicks) { + float prev = rotorAngleO; + if (rotorAngle - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - rotorAngle < -180.0F) { + prev += 360.0F; + } + return prev + (rotorAngle - prev) * partialTicks; + } + + /** Pilot toggles hover mode. Only allowed while fairly level and not in gunner mode, as in the original. */ + public void toggleHovering() { + if (isHoveringMode()) { + setHovering(false); + } else if (!isGunnerMode() && isLevelEnough()) { + setHovering(true); + } + } + + private boolean isLevelEnough() { + return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; + } + + /** The original also needed the blades unfolded; folding is not ported yet. */ + @Override + public boolean canSwitchGunnerMode() { + return super.canSwitchGunnerMode() && isLevelEnough(); + } + + @Override + public void applyPilotLook(float dYaw, float dPitch) { + HelicopterDefinition def = definition(); + if (def == null) { + return; + } + float x = dYaw * MOUSE_COUNTS_PER_DEGREE; + float y = dPitch * MOUSE_COUNTS_PER_DEGREE; + + float yaw = 0.0F; + float pitch = 0.0F; + float rollDelta = 0.0F; + if (!isNearGround(2)) { + yaw = clamp(x, 40.0F * def.mobilityYaw) * LOOK_SCALE; + } + if (!isHovering()) { + pitch = clamp(y, 40.0F * def.mobilityPitch) * LOOK_SCALE; + rollDelta = clamp(x, 40.0F * def.mobilityRoll) * LOOK_SCALE; + } + if (yaw == 0.0F && pitch == 0.0F && rollDelta == 0.0F) { + return; + } + + float[] r = AircraftRotation.applyLocal(getYRot(), getXRot(), roll, yaw, pitch, rollDelta); + float newPitch = r[1]; + float newRoll = r[2]; + if (def.limitRotation) { + newPitch = Mth.clamp(newPitch, def.minRotationPitch, def.maxRotationPitch); + newRoll = Mth.clamp(newRoll, def.minRotationRoll, def.maxRotationRoll); + } + // Keep yaw continuous so the pilot's own yaw never jumps across the +-180 seam. + setYRot(getYRot() + Mth.wrapDegrees(r[0] - getYRot())); + setXRot(newPitch); + roll = newRoll; + } + + private static float clamp(float v, float limit) { + return Mth.clamp(v, -limit, limit); + } + + @Override + protected void simulate() { + HelicopterDefinition def = definition(); + boolean piloted = getControllingPassenger() != null; + + updateThrottle(def, piloted); + updateAttitude(def); + updateMotion(def); + } + + /** {@code onUpdate_Control} and the hover/non-hover branches. */ + private void updateThrottle(HelicopterDefinition def, boolean piloted) { + if (piloted) { + Vec3 motion = getDeltaMovement(); + if (!isHovering()) { + if (input.throttleUp) { + throttle = Math.min(1.0, throttle + 0.02 * def.throttleUpDown); + } else if (input.throttleDown) { + throttle = Math.max(0.0, throttle - (1.0 / 70.0) * def.throttleUpDown); + } + motion = strafe(motion, false, input.left, input.right, 0.02 * def.speed); + } else { + throttle = Math.min(1.0, throttle + 1.0 / 30.0); + motion = strafe(motion, input.throttleUp, input.left, input.right, 0.01 * def.speed); + if (input.throttleDown) { + motion = push(motion, getYRot() - 180.0F, 0.01 * def.speed); + } + } + setDeltaMovement(motion); + } else { + throttle = Math.max(0.0, throttle - 0.00125); + if (isHoveringMode() && !level().isClientSide()) { + setHovering(false); + } + } + } + + private Vec3 strafe(Vec3 motion, boolean forward, boolean left, boolean right, double amount) { + if (forward) { + motion = push(motion, getYRot(), amount); + } + if (left && !right) { + motion = push(motion, getYRot() - 90.0F, amount); + } else if (right && !left) { + motion = push(motion, getYRot() + 90.0F, amount); + } + return motion; + } + + /** Adds {@code amount} of horizontal motion towards {@code yaw}, Minecraft style (0 = south). */ + private static Vec3 push(Vec3 motion, float yaw, double amount) { + double rad = Math.toRadians(yaw); + return motion.add(-Math.sin(rad) * amount, 0.0, Math.cos(rad) * amount); + } + + /** Per-tick version of {@code onUpdateAngles}: roll levels out, A/D bank in the air. */ + private void updateAttitude(HelicopterDefinition def) { + float damping = 1.0F - (isHovering() ? ROLL_DAMPING_HOVER : ROLL_DAMPING); + if (Math.abs(roll) > 0.1F && Math.abs(roll) < 65.0F) { + roll *= damping; + } + if (getControllingPassenger() != null && !isNearGround(3)) { + if (input.left && !input.right) { + roll -= ROLL_RATE; + } else if (input.right && !input.left) { + roll += ROLL_RATE; + } + } + if (isHovering() && Math.abs(getXRot()) < 70.0F) { + setXRot(getXRot() * 0.95F); + } + if (def.limitRotation) { + roll = Mth.clamp(roll, def.minRotationRoll, def.maxRotationRoll); + } + } + + /** {@code onUpdate_Server}: gravity, lift, forward thrust, speed limit and ground friction. */ + private void updateMotion(HelicopterDefinition def) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevMotion = Math.sqrt(mx * mx + mz * mz); + float groundPitch = def.onGroundPitch; + + if (!isHovering()) { + double depth = def.floats ? waterDepth() : 0.0; + if (depth == 0.0) { + my += isInWater() ? def.gravityInWater : def.gravity; + double yawRad = Math.toRadians(getYRot()); + float pitch = getXRot(); + if (isNearGround(3)) { + pitch -= groundPitch; + } + double thrust = 0.1 * currentSpeed * (pitch * pitch * pitch / 30000.0F) * throttle; + mx += Math.sin(yawRad) * -thrust; + mz += Math.cos(yawRad) * thrust; + + double tilt = (Math.abs(getXRot()) + Math.abs(roll)) * 0.6; + double lift = tilt <= 50.0 ? 1.0 - tilt / 50.0 : 0.0; + my += (lift * 0.025 + 0.03) * throttle; + } else { + levelOut(groundPitch); + if (depth < 1.0) { + my -= 1.0E-4; + my += 0.007 * throttle; + } else { + if (my < 0.0) { + my *= 0.7; + } + my += 0.007; + } + } + } else { + if (random.nextInt(50) == 0) { + mx += (random.nextDouble() - 0.5) / 30.0; + } + if (random.nextInt(50) == 0) { + my += (random.nextDouble() - 0.5) / 50.0; + } + if (random.nextInt(50) == 0) { + mz += (random.nextDouble() - 0.5) / 30.0; + } + } + + double motion = Math.sqrt(mx * mx + mz * mz); + double limit = def.speed; + if (motion > limit) { + mx *= limit / motion; + mz *= limit / motion; + motion = limit; + } + if (motion > prevMotion && currentSpeed < limit) { + currentSpeed = Math.min(limit, currentSpeed + (limit - currentSpeed) / 35.0); + } else { + currentSpeed = Math.max(MIN_SPEED, currentSpeed - (currentSpeed - MIN_SPEED) / 35.0); + } + + if (onGround()) { + mx *= 0.5; + mz *= 0.5; + levelOut(groundPitch); + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + private void levelOut(float groundPitch) { + if (Math.abs(getXRot()) < 40.0F) { + setXRot((getXRot() - groundPitch) * 0.9F + groundPitch); + } + if (Math.abs(roll) < 40.0F) { + roll *= 0.9F; + } + } + + @Override + protected void tickAnimation() { + HelicopterDefinition def = definition(); + rotorAngleO = rotorAngle; + float spool = 1.0F - visibleThrottle(); + rotorAngle = (rotorAngle + (1.0F - spool * spool * spool) * def.rotorSpeed) % 360.0F; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java new file mode 100644 index 0000000..54755d5 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java @@ -0,0 +1,122 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; + +/** + * Open/closed state of a group of moving parts (canopy, landing gear, hatches, wings, VTOL nozzles). + * Ported from {@code MCH_Parts}. + * + *

The target state is one bit of a synched integer, so only the server changes it and every + * side sees the same value. The angle runs from 0 to {@link #max} at {@link #speed} degrees per tick + * and is advanced locally on every side, which keeps the animation smooth without sending angles. + */ +public final class PartState { + private final AircraftEntity owner; + private final int bit; + private final float max; + private final float speed; + private final Sounds sounds; + + private float angle; + private float prevAngle; + + PartState(AircraftEntity owner, int bit, float speed, Sounds sounds) { + this.owner = owner; + this.bit = bit; + this.max = 90.0F; + this.speed = speed; + this.sounds = sounds; + } + + /** Target state: {@code true} means open, folded, retracted or tilted, depending on the part. */ + public boolean status() { + return (owner.partBits() & (1 << bit)) != 0; + } + + /** Fully back in the default position. */ + public boolean isOff() { + return !status() && angle <= 0.02F; + } + + /** Fully in the switched position. */ + public boolean isOn() { + return status() && angle >= max - 0.02F; + } + + public float angle() { + return angle; + } + + public float angle(float partialTicks) { + return prevAngle + (angle - prevAngle) * partialTicks; + } + + /** Progress from 0 to 1. */ + public float factor() { + return angle / max; + } + + public float factor(float partialTicks) { + return angle(partialTicks) / max; + } + + /** Changes the target state. Server only; clients follow through synched data. */ + void set(boolean on, boolean playSound) { + if (owner.level().isClientSide() || status() == on) { + return; + } + owner.setPartBit(bit, on); + if (playSound) { + play(sounds.switching); + play(on ? sounds.startOn : sounds.startOff); + } + tick(); + } + + /** Jumps straight to {@code on} without animating, used for parts that start deployed. */ + void force(boolean on) { + if (!owner.level().isClientSide()) { + owner.setPartBit(bit, on); + } + angle = prevAngle = on ? max : 0.0F; + } + + void tick() { + prevAngle = angle; + if (status()) { + if (angle < max) { + angle = Math.min(angle + speed, max); + if (angle >= max) { + play(sounds.endOn); + } + } + } else if (angle > 0.0F) { + angle = Math.max(angle - speed, 0.0F); + if (angle <= 0.0F) { + play(sounds.endOff); + } + } + } + + private void play(Cue cue) { + if (cue != null && !owner.level().isClientSide()) { + ModSounds.playAt(owner, cue.sound(), 1.0F, cue.pitch()); + } + } + + /** One sound from {@code sounds.json} with its pitch. */ + record Cue(String sound, float pitch) { + } + + /** What plays when the part starts and finishes moving each way; {@code null} entries stay silent. */ + record Sounds(Cue startOn, Cue endOn, Cue startOff, Cue endOff, Cue switching) { + private static final Cue CLICK = new Cue("plane_cc", 1.0F); + private static final Cue CLICK_LOW = new Cue("plane_cc", 0.5F); + + static final Sounds HATCH = new Sounds(null, CLICK, null, CLICK, new Cue("plane_cv", 0.5F)); + static final Sounds CANOPY = new Sounds(null, CLICK, null, CLICK, null); + static final Sounds LANDING_GEAR = new Sounds(CLICK_LOW, CLICK_LOW, CLICK_LOW, CLICK_LOW, new Cue("plane_cv", 0.75F)); + static final Sounds NOZZLE = new Sounds(CLICK_LOW, CLICK_LOW, CLICK_LOW, CLICK_LOW, new Cue("plane_cv", 0.5F)); + static final Sounds WING = new Sounds(CLICK_LOW, CLICK_LOW, CLICK_LOW, CLICK_LOW, null); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java new file mode 100644 index 0000000..b80171b --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java @@ -0,0 +1,516 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A fixed wing aircraft, ported from {@code MCP_EntityPlane}. + * + *

Throttle pushes the plane along its nose, tilted ten degrees up, and the vertical part of + * that push is what keeps it in the air: below full throttle it sinks. VTOL capable planes tilt + * their nozzles (and tilt rotors) towards straight down, which turns the push into lift. + * + *

The original applied its attitude rules once per rendered frame with the partial tick + * filtered to about 0.5. Here they run three times per tick with that value, which is what a + * 60 FPS client did, so the handling is the same without depending on the frame rate. + */ +public class PlaneEntity extends AircraftEntity implements EngineSoundSource { + private static final float LOOK_SCALE = 0.03F; + private static final float MOUSE_COUNTS_PER_DEGREE = 1.0F / 0.15F; + private static final int FRAMES_PER_TICK = 3; + private static final float FRAME_PARTIAL_TICK = 0.5F; + private static final double MIN_SPEED = 0.07; + + private @Nullable PartState nozzle; + private @Nullable PartState wing; + + private double currentSpeed = MIN_SPEED; + private float keyRoll; + private float rotorAngle; + public float rotorAngleO; + private float soundLevel; + + public PlaneEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected @Nullable PlaneDefinition lookup(String name) { + return MCHeli.content().plane(name); + } + + @Override + public @Nullable PlaneDefinition definition() { + return (PlaneDefinition) super.definition(); + } + + @Override + protected void onDefinitionChanged(AircraftDefinition definition) { + super.onDefinitionChanged(definition); + PlaneDefinition def = (PlaneDefinition) definition; + nozzle = def.hasNozzleState() ? addPart(PART_NOZZLE, 1.5F, PartState.Sounds.NOZZLE) : null; + wing = def.wings.isEmpty() ? null : addPart(PART_WING, 2.5F, PartState.Sounds.WING); + if (nozzle != null && def.defaultVtol && !nozzle.status()) { + nozzle.force(true); + } + } + + @Override + public boolean steersWithMouse() { + return true; + } + + public @Nullable PartState nozzle() { + return nozzle; + } + + public @Nullable PartState wing() { + return wing; + } + + public float nozzleAngle() { + return nozzle != null ? nozzle.angle() : 0.0F; + } + + @Override + public int vtolMode() { + if (nozzle == null) { + return 0; + } + if (!nozzle.status()) { + return nozzle.angle() <= 0.005F ? 0 : 1; + } + return nozzle.angle() >= 89.995F ? 2 : 1; + } + + public float rotorAngle(float partialTicks) { + return rotorAngleO + (rotorAngle - rotorAngleO) * partialTicks; + } + + // Pilot actions, server side + + public boolean canSwitchVtol() { + PlaneDefinition def = definition(); + if (def == null || !def.vtol || nozzle == null || modeSwitchCooldown > 0 || vtolMode() == 1) { + return false; + } + return Math.abs(roll) <= 30.0F && !(onGround() && def.defaultVtol); + } + + public void toggleVtol() { + if (canSwitchVtol()) { + modeSwitchCooldown = 20; + setVtol(!nozzle.status()); + } + } + + private void setVtol(boolean on) { + PlaneDefinition def = definition(); + if (nozzle == null || def == null || (def.defaultVtol && onGround() && !on)) { + return; + } + nozzle.set(on, true); + } + + public boolean canFoldWing() { + PlaneDefinition def = definition(); + if (wing == null || def == null || modeSwitchCooldown > 0) { + return false; + } + if (def.variableSweepWing) { + // Sweep back only when fast and well clear of the ground, spread again only when slow. + if (!onGround() && !hasBlockBelow(3, 20, true)) { + if (throttle < 0.7F) { + return false; + } + } else if (throttle > 0.1F) { + return false; + } + } else { + if (!onGround() && !hasBlockBelow(3, 3, true)) { + return false; + } + if (throttle > 0.01F) { + return false; + } + } + return wing.isOff(); + } + + public boolean canUnfoldWing() { + return wing != null && modeSwitchCooldown <= 0 && wing.isOn(); + } + + @Override + public void toggleFold() { + PlaneDefinition def = definition(); + if (def == null) { + return; + } + if (!def.hatches.isEmpty()) { + super.toggleFold(); + } else if (canFoldWing()) { + wing.set(true, true); + modeSwitchCooldown = 20; + } else if (canUnfoldWing()) { + wing.set(false, true); + modeSwitchCooldown = 20; + } + } + + /** Folded wings ground the plane; swept wings only below 20% throttle. */ + private boolean canUseWing(PlaneDefinition def) { + if (wing == null) { + return true; + } + if (def.variableSweepWing) { + return throttle >= 0.2 || wing.isOff(); + } + return wing.isOff(); + } + + @Override + protected float autoLowerGearThrottle() { + return 0.7F; + } + + @Override + protected void updatePartsServer(AircraftDefinition definition) { + super.updatePartsServer(definition); + PlaneDefinition def = (PlaneDefinition) definition; + // Swept wings spread out again when the plane slows down or gets close to the ground. + if (wing != null && def.variableSweepWing && wing.isOn() && throttle >= 0.2F + && (throttle < 0.5 || hasBlockBelow(1, 10, true))) { + wing.set(false, true); + } + if (nozzle != null && def.defaultVtol && onGround() && vtolMode() == 0) { + nozzle.set(true, true); + } + } + + // Mouse + + @Override + public void applyPilotLook(float dYaw, float dPitch) { + PlaneDefinition def = definition(); + if (def == null || ticksAlive() < 30 || isPassenger() || hasBlockBelow(3, 2, true)) { + return; + } + float x = dYaw * MOUSE_COUNTS_PER_DEGREE; + float y = dPitch * MOUSE_COUNTS_PER_DEGREE; + boolean vtol = vtolMode() > 0; + float yawFactor = (vtol ? def.vtolYaw : 1.0F) * 0.8F; + float pitchFactor = (vtol ? def.vtolPitch : 1.0F) * 0.8F; + float rollInput = vtolMode() == 0 ? x * 0.5F : x; + + float yaw = clamp(x, 40.0F * def.mobilityYaw) * yawFactor * LOOK_SCALE; + float pitch = clamp(y, 40.0F * def.mobilityPitch) * pitchFactor * LOOK_SCALE; + float rollDelta = clamp(rollInput, 40.0F * def.mobilityRoll) * yawFactor * LOOK_SCALE; + if (yaw == 0.0F && pitch == 0.0F && rollDelta == 0.0F) { + return; + } + float[] r = AircraftRotation.applyLocal(getYRot(), getXRot(), roll, yaw, pitch, rollDelta); + float newPitch = r[1]; + float newRoll = r[2]; + if (def.limitRotation) { + newPitch = Mth.clamp(newPitch, def.minRotationPitch, def.maxRotationPitch); + newRoll = Mth.clamp(newRoll, def.minRotationRoll, def.maxRotationRoll); + } + setYRot(getYRot() + Mth.wrapDegrees(r[0] - getYRot())); + setXRot(newPitch); + roll = newRoll; + } + + private static float clamp(float v, float limit) { + return Mth.clamp(v, -limit, limit); + } + + // Simulation + + @Override + protected void simulate() { + PlaneDefinition def = definition(); + boolean piloted = getControllingPassenger() != null; + if (def.targetDrone && !piloted) { + input.throttleUp = true; + } + updateThrottle(def, piloted); + if (piloted) { + for (int i = 0; i < FRAMES_PER_TICK; i++) { + updateAttitude(def, FRAME_PARTIAL_TICK); + } + } + updateMotion(def); + } + + /** + * Gunner mode needs a plane in level flight with throttle up and nothing solid within five + * blocks below, so the autopilot has room to circle. + */ + @Override + public boolean canSwitchGunnerMode() { + if (!super.canSwitchGunnerMode()) { + return false; + } + float r = Math.abs(Mth.wrapDegrees(roll())); + float p = Math.abs(Mth.wrapDegrees(getXRot())); + return r <= 40.0F && p <= 40.0F && visibleThrottle() > 0.6F && !hasBlockBelow(3, 5, false); + } + + /** {@code onUpdate_Control} and {@code onUpdate_ControlNotHovering}. */ + private void updateThrottle(PlaneDefinition def, boolean piloted) { + reverse *= 0.8F; + boolean canFly = piloted && isCanopyClosed() && canUseWing(def); + if (canFly && isGunnerMode()) { + // The autopilot holds the throttle the pilot left it at. + return; + } + if (canFly || def.targetDrone) { + float upDown = def.throttleUpDown; + boolean turning = input.left != input.right; + boolean up = input.throttleUp; + if (turning && throttle < def.pivotTurnThrottle && !up && !input.throttleDown) { + up = true; + upDown *= 2.0F; + } + if (up) { + if (def.enableBack && reverse > 0.0F) { + reverse -= 0.01F * upDown; + } else { + reverse = 0.0F; + throttle = Math.min(throttle + 0.01 * upDown, 1.0); + } + } else if (input.throttleDown) { + if (throttle > 0.0) { + throttle -= 0.01 * upDown; + } else { + throttle = 0.0; + if (def.enableBack) { + reverse = Math.min(reverse + 0.0025F * upDown, 0.6F); + } + } + } + } else if (throttle > 0.0) { + throttle -= 0.0025 * def.throttleUpDown; + } + throttle = Math.max(throttle, 0.0); + } + + /** One frame of {@code onUpdateAngles}. In gunner mode the autopilot levels out and circles by {@code AutoPilotRot}. */ + private void updateAttitude(PlaneDefinition def, float pt) { + boolean gunner = isGunnerMode(); + if (gunner) { + setXRot(getXRot() * 0.95F); + setYRot(getYRot() + def.autoPilotRot * 0.2F); + if (Math.abs(roll) > 20.0F) { + roll *= 0.95F; + } + } + boolean flying = !hasBlockBelow(3, 3, true); + if (!flying || input.freeLook || gunner || (def.floats && waterDepth() > 0.0)) { + float yawRate = 1.0F; + if (!flying) { + yawRate = def.mobilityYawOnGround; + if (!def.canRotateOnGround && isOnSolidGround()) { + yawRate = 0.0F; + } + } + if (input.left && !input.right) { + setYRot(getYRot() - 0.6F * yawRate * pt); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.6F * yawRate * pt); + } + } else { + // A/D bank the plane; in VTOL mode the keys do nothing in the air. + float rate = vtolMode() != 0 ? 0.0F : 0.2F; + if (input.left && !input.right) { + keyRoll -= rate * pt; + } else if (input.right && !input.left) { + keyRoll += rate * pt; + } + roll += keyRoll * 0.5F * def.mobilityRoll; + } + keyRoll *= 1.0F - 0.1F * pt; + if (!flying && Math.abs(getXRot()) < 40.0F) { + applyOnGroundPitch(0.97F); + } + if (nozzleAngle() > 0.001F) { + setXRot(getXRot() * (1.0F - 0.03F * pt)); + roll *= 1.0F - 0.1F * pt; + } + roll = AircraftRotation.wrap(roll); + if (def.limitRotation) { + setXRot(Mth.clamp(getXRot(), def.minRotationPitch, def.maxRotationPitch)); + roll = Mth.clamp(roll, def.minRotationRoll, def.maxRotationRoll); + } + } + + private float maxSpeed(PlaneDefinition def) { + float extra = 0.0F; + if (def.variableSweepWing) { + if (wing != null) { + extra = (def.sweepWingSpeed - def.speed) * wing.factor(); + } else if (hatch != null) { + extra = (def.sweepWingSpeed - def.speed) * hatch.factor(); + } + } + return def.speed + extra; + } + + /** {@code onUpdate_Server}. */ + private void updateMotion(PlaneDefinition def) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevMotion = Math.sqrt(mx * mx + mz * mz); + double depth = def.floats ? waterDepth() : 0.0; + // The gunner mode autopilot holds its altitude. + boolean levelOff = isGunnerMode(); + + if (depth == 0.0) { + if (def.targetDrone) { + levelOff = targetDroneAutopilot(def) || levelOff; + } + if (!levelOff) { + my += 0.04 + (isInWater() ? def.gravityInWater : def.gravity); + my += -0.047 * (1.0 - throttle); + } else { + my *= 0.8; + } + } else { + setXRot(getXRot() * 0.8F); + if (Math.abs(roll) < 40.0F) { + roll *= 0.9F; + } + if (depth < 1.0) { + my -= 1.0E-4; + my += 0.007 * throttle; + } else { + if (my < 0.0) { + my /= 2.0; + } + my += 0.007; + } + } + + double push = throttle / 10.0; + float nozzleAngle = nozzleAngle(); + Vec3 v; + if (nozzleAngle > 0.001F) { + setXRot(getXRot() * 0.95F); + v = direction(getYRot(), getXRot() - nozzleAngle); + if (nozzleAngle >= 90.0F) { + v = new Vec3(v.x * 0.8, v.y, v.z * 0.8); + } + } else { + v = direction(getYRot(), getXRot() - 10.0F); + } + if (!levelOff) { + my += v.y * push / (nozzleAngle <= 0.01F ? 2.0 : 8.0); + } + + boolean canMove = def.canMoveOnGround || !isOnSolidGround(); + if (canMove) { + if (def.enableBack && reverse > 0.0F) { + mx -= v.x * reverse; + mz -= v.z * reverse; + } else { + mx += v.x * push; + mz += v.z * push; + } + } + + double motion = Math.sqrt(mx * mx + mz * mz); + float limit = maxSpeed(def); + if (motion > limit) { + mx *= limit / motion; + mz *= limit / motion; + motion = limit; + } + if (motion > prevMotion && currentSpeed < limit) { + currentSpeed = Math.min(limit, currentSpeed + (limit - currentSpeed) / 35.0); + } else { + currentSpeed = Math.max(MIN_SPEED, currentSpeed - (currentSpeed - MIN_SPEED) / 35.0); + } + + if (onGround() || hasBlockBelow(1, 2, true)) { + mx *= def.motionFactor; + mz *= def.motionFactor; + if (Math.abs(getXRot()) < 40.0F) { + applyOnGroundPitch(0.8F); + } + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * def.motionFactor, my * 0.95, mz * def.motionFactor); + } + + /** Target drones circle at a safe height on their own. Returns whether to hold altitude. */ + private boolean targetDroneAutopilot(PlaneDefinition def) { + if (hasBlockBelow(3, 40, true)) { + if (!hasBlockBelow(3, 5, true)) { + setYRot(getYRot() + def.autoPilotRot * 2.0F); + if (getXRot() > -20.0F) { + setXRot(getXRot() - 0.5F); + } + } + return false; + } + setYRot(getYRot() + def.autoPilotRot); + setXRot(getXRot() * 0.95F); + if (!level().isClientSide() && canRaiseLandingGear() && modeSwitchCooldown <= 0) { + toggleLandingGear(); + } + return true; + } + + /** {@code MCH_Lib.Rot2Vec3}: unit vector along a yaw and pitch, Minecraft conventions. */ + private static Vec3 direction(float yaw, float pitch) { + double y = Math.toRadians(yaw); + double p = Math.toRadians(pitch); + return new Vec3(-Math.sin(y) * Math.cos(p), -Math.sin(p), Math.cos(y) * Math.cos(p)); + } + + // Animation and sound + + @Override + protected void tickAnimation() { + PlaneDefinition def = definition(); + float throttle = visibleThrottle(); + rotorAngleO = rotorAngle; + rotorAngle += throttle * def.rotorSpeed; + if (rotorAngle > 360.0F) { + rotorAngle -= 360.0F; + rotorAngleO -= 360.0F; + } else if (rotorAngle < 0.0F) { + rotorAngle += 360.0F; + rotorAngleO += 360.0F; + } + + float target = throttle; + if (getControllingPassenger() != null && (canopy == null || canopy.angle() < 1.0F)) { + target += 0.1F; + } + soundLevel = soundLevel < target ? Math.min(soundLevel + 0.02F, target) : Math.max(soundLevel - 0.02F, target); + } + + @Override + public float engineVolume() { + PlaneDefinition def = definition(); + return def != null && def.throttleUpDown <= 0.0F ? 0.0F : soundLevel * 0.7F; + } + + @Override + public float enginePitch() { + return 0.6F + visibleThrottle() * 0.4F; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java new file mode 100644 index 0000000..e729c7f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java @@ -0,0 +1,267 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.physics.GroundImpact; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Tanks and cars, ported from {@code MCH_EntityTank}. + * + *

The drive model is the plane's minus lift: throttle pushes along the hull, A/D turn it, + * S brakes and then reverses when {@code EnableBack} is set, and Space is a hand brake. The hull + * does not follow the mouse. Instead a {@link TerrainFollower} reads the terrain under the + * wheels and the hull eases towards that pitch and roll, while the crew look around freely and + * the turret follows their view. + */ +public class TankEntity extends AircraftEntity implements EngineSoundSource { + private static final int FRAMES_PER_TICK = 3; + private static final float FRAME_PARTIAL_TICK = 0.5F; + private static final double MIN_SPEED = 0.07; + + private final TerrainFollower terrain = new TerrainFollower(this); + private double currentSpeed = MIN_SPEED; + /** + * Squared horizontal distance covered by last tick's move. The original read + * {@code posX - prevPosX} here, but vanilla resets {@code xo} before {@link #simulate()} runs, + * which left vehicles with {@code PivotTurnThrottle} unable to steer. + */ + private double lastStepSq; + private float soundLevel; + private float turnSound; + + public TankEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected @Nullable TankDefinition lookup(String name) { + return MCHeli.content().tank(name); + } + + @Override + public @Nullable TankDefinition definition() { + return (TankDefinition) super.definition(); + } + + @Override + protected void onDefinitionChanged(AircraftDefinition definition) { + super.onDefinitionChanged(definition); + TankDefinition def = (TankDefinition) definition; + terrain.create(def.groundWheels, new Vector3(0.0, -0.35, def.weightedCenterZ)); + } + + /** Walls and long drops hurt tanks and cars, scaled by speed and height. */ + @Override + protected boolean takesGroundImpacts() { + return true; + } + + /** Tanks climb and tilt on slopes all the time; only rolling over damages them. */ + @Override + protected float crashTilt() { + return 91.0F; + } + + @Override + protected void simulate() { + TankDefinition def = definition(); + boolean piloted = getControllingPassenger() != null; + updateThrottle(def, piloted); + if (piloted) { + for (int i = 0; i < FRAMES_PER_TICK; i++) { + updateAttitude(def, FRAME_PARTIAL_TICK); + } + if (def.limitRotation) { + setXRot(Mth.clamp(getXRot(), -90.0F, 90.0F)); + roll = Mth.clamp(roll, -90.0F, 90.0F); + } + } + updateMotion(def, piloted); + } + + /** {@code onUpdate_Control} and {@code onUpdate_ControlSub}. */ + private void updateThrottle(TankDefinition def, boolean piloted) { + reverse *= 0.8F; + float upDown = def.throttleUpDown; + if (input.brake) { + reverse *= 0.5F; + throttle = Math.max(throttle - 0.02 * upDown, 0.0); + } + if ((piloted && isCanopyClosed()) || def.targetDrone) { + if (input.throttleUp || (def.targetDrone && !piloted)) { + if (def.enableBack && reverse > 0.0F) { + reverse -= 0.01F * upDown; + } else { + reverse = 0.0F; + throttle = Math.min(throttle + 0.01 * upDown, 1.0); + } + } else if (input.throttleDown) { + if (throttle > 0.0) { + throttle -= 0.01 * upDown; + } else { + throttle = 0.0; + if (def.enableBack) { + reverse = Math.min(reverse + 0.0025F * upDown, 0.6F); + } + } + } + } else if (throttle > 0.0) { + throttle -= 0.0025 * upDown; + } + throttle = Math.max(throttle, 0.0); + } + + /** One frame of {@code onUpdateAngles}: ease onto the terrain, steer with A/D. */ + private void updateAttitude(TankDefinition def, float pt) { + setXRot(getXRot() + (terrain.targetPitch - getXRot()) * pt); + roll += (terrain.targetRoll - roll) * pt; + + boolean flying = !hasBlockBelow(3, 3, true); + if (flying && !(def.floats && waterDepth() > 0.0)) { + return; + } + float yawRate = 1.0F; + if (!flying) { + yawRate = def.mobilityYawOnGround; + if (!def.canRotateOnGround && isOnSolidGround()) { + yawRate = 0.0F; + } + } + float pivot = def.pivotTurnThrottle; + double dist = lastStepSq; + // With PivotTurnThrottle set the vehicle has to be rolling before it can turn, like a car. + if (pivot <= 0.0F || throttle >= pivot || reverse >= pivot / 10.0F || dist > reverse * 0.01) { + float speedScale = pivot <= 0.0F ? 1.0F : (float) Math.sqrt(Math.min(dist, 1.0)); + float direction = !input.throttleUp && input.throttleDown && throttle < pivot + 0.05 ? -1.0F : 1.0F; + float step = 0.6F * yawRate * pt * direction * speedScale; + if (input.left && !input.right) { + setYRot(getYRot() - step); + } else if (input.right && !input.left) { + setYRot(getYRot() + step); + } + } + } + + /** {@code onUpdate_Server}. */ + private void updateMotion(TankDefinition def, boolean piloted) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevMotion = Math.sqrt(mx * mx + mz * mz); + double depth = def.floats ? waterDepth() : 0.0; + + if (depth == 0.0) { + my += 0.04 + (isInWater() ? def.gravityInWater : def.gravity); + my += -0.047 * (1.0 - throttle); + } else if (depth < 1.0) { + my -= 1.0E-4; + my += 0.007 * throttle; + } else { + if (my < 0.0) { + my /= 2.0; + } + my += 0.007; + } + + double push = throttle / 10.0; + double yawRad = Math.toRadians(getYRot()); + double pitchRad = Math.toRadians(getXRot() - 10.0F); + double vx = -Math.sin(yawRad) * Math.cos(pitchRad); + double vy = -Math.sin(pitchRad); + double vz = Math.cos(yawRad) * Math.cos(pitchRad); + my += vy * push / 8.0; + + if (def.canMoveOnGround || !isOnSolidGround()) { + if (def.enableBack && reverse > 0.0F) { + mx -= vx * reverse; + mz -= vz * reverse; + } else { + mx += vx * push; + mz += vz * push; + } + } + + double motion = Math.sqrt(mx * mx + mz * mz); + float limit = def.speed; + if (motion > limit) { + mx *= limit / motion; + mz *= limit / motion; + motion = limit; + } + if (motion > prevMotion && currentSpeed < limit) { + currentSpeed = Math.min(limit, currentSpeed + (limit - currentSpeed) / 35.0); + } else { + currentSpeed = Math.max(MIN_SPEED, currentSpeed - (currentSpeed - MIN_SPEED) / 35.0); + } + + if (onGround() || hasBlockBelow(1, 2, true)) { + mx *= def.motionFactor; + mz *= def.motionFactor; + } + + if (!terrain.isEmpty()) { + Vec3 slide = terrain.move(mx, mz); + mx += slide.x; + mz += slide.z; + if (!piloted) { + // Nobody is easing the hull in per frame, so take the attitude straight away. + setXRot(terrain.targetPitch); + roll = terrain.targetRoll; + } + } + + double startX = getX(); + double startZ = getZ(); + move(MoverType.SELF, new Vec3(mx, my, mz)); + double stepX = getX() - startX; + double stepZ = getZ() - startZ; + lastStepSq = stepX * stepX + stepZ * stepZ; + setDeltaMovement(mx * def.motionFactor, my * 0.95, mz * def.motionFactor); + } + + /** The original's {@code applyOnGroundPitch} is a no-op for tanks; the terrain decides. */ + @Override + protected void applyOnGroundPitch(float factor) { + } + + @Override + protected float autoLowerGearThrottle() { + return 0.7F; + } + + @Override + protected void tickAnimation() { + float target = visibleThrottle(); + if (getControllingPassenger() != null && (canopy == null || canopy.angle() < 1.0F)) { + target += 0.1F; + } + // Turning on the spot still revs the engine. + if (!input.left && !input.right && !input.throttleDown) { + turnSound *= 0.8F; + } else { + turnSound = Math.min(turnSound + 0.1F, 0.75F); + } + target = Math.max(target, turnSound); + soundLevel = soundLevel < target ? Math.min(soundLevel + 0.02F, target) : Math.max(soundLevel - 0.02F, target); + } + + @Override + public float engineVolume() { + TankDefinition def = definition(); + return def != null && def.throttleUpDown <= 0.0F ? 0.0F : soundLevel * 0.7F; + } + + @Override + public float enginePitch() { + return Math.max(0.5F + visibleThrottle() * 0.5F, 0.5F + turnSound * 0.5F); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java new file mode 100644 index 0000000..92e8e59 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java @@ -0,0 +1,225 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * Works out the pitch and roll of a ground vehicle from where its wheels touch the terrain. + * Ported from {@code MCH_WheelManager}. + * + *

The original spawned an invisible entity per wheel and let each one fall onto the ground + * under its mount point. Here every wheel is a one block collision box moved with vanilla's + * collision code, which gives the same result without extra entities to sync. Each left/right + * pair spans a plane with the centre of gravity; the weighted average of their normals is the + * ground normal the hull leans towards. + */ +final class TerrainFollower { + private static final double STEP_HEIGHT = 1.5; + private static final double MAX_DRIFT = 2.0; + private static final double SNAP_DISTANCE = 8.0; + + private final AircraftEntity vehicle; + private Probe[] probes = new Probe[0]; + private Vector3 centre = Vector3.ZERO; + private double wheelBase = 1.0; + float targetPitch; + float targetRoll; + + TerrainFollower(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + /** + * @param wheels positive X wheel positions, front to back; the mirrored wheel is added for each + * @param centre centre of gravity in hull space + */ + void create(List wheels, Vector3 centre) { + this.centre = centre; + probes = new Probe[wheels.size() * 2]; + double minZ = Double.MAX_VALUE; + double maxZ = -Double.MAX_VALUE; + for (int i = 0; i < probes.length; i++) { + Vector3 w = wheels.get(i / 2); + Vector3 local = new Vector3(i % 2 == 0 ? w.x() : -w.x(), w.y(), w.z()); + Probe probe = new Probe(local, local.z() >= centre.z()); + Vec3 at = vehicle.toWorld(local); + probe.setPos(at.x, at.y + 1.0, at.z); + probes[i] = probe; + minZ = Math.min(minZ, local.z()); + maxZ = Math.max(maxZ, local.z()); + } + wheelBase = Math.max(maxZ - minZ, 1.0E-3); + } + + boolean isEmpty() { + return probes.length == 0; + } + + /** + * Drags the wheels along with the hull, lets them settle on the terrain and derives the target + * attitude from them. + * + * @return extra horizontal motion from sliding down steep slopes + */ + Vec3 move(double mx, double mz) { + for (Probe probe : probes) { + Vec3 target = vehicle.toWorld(probe.local); + if (target.distanceToSqr(probe.x, probe.y, probe.z) > SNAP_DISTANCE * SNAP_DISTANCE) { + // Teleported, or created before the vehicle was placed: start over under the hull. + probe.setPos(target.x, target.y + 1.0, target.z); + } + double dx = target.x - probe.x + mx; + double dy = (target.y - probe.y) * 0.15; + double dz = target.z - probe.z + mz; + probe.move(dx, dy, dz); + probe.move(0.0, -0.1, 0.0); + } + + int pairs = probes.length / 2; + // If neither half touches the ground, the outermost pair of that half carries the hull, + // which makes the vehicle tip over a crest instead of floating level. + int rear = pairs - 1; + for (int i = 0; i < pairs; i++) { + if (!probes[i * 2].front && (probes[i * 2].onGround || probes[i * 2 + 1].onGround)) { + rear = -1; + break; + } + } + if (rear >= 0) { + probes[rear * 2].onGround = true; + probes[rear * 2 + 1].onGround = true; + } + int front = 0; + for (int i = pairs - 1; i >= 0; i--) { + if (probes[i * 2].front && (probes[i * 2].onGround || probes[i * 2 + 1].onGround)) { + front = -1; + break; + } + } + if (front >= 0 && pairs > 0) { + probes[front * 2].onGround = true; + probes[front * 2 + 1].onGround = true; + } + + Vec3 wc = vehicle.toWorld(centre).subtract(vehicle.position()); + double cx = vehicle.getX() + wc.x; + double cy = vehicle.getY() + centre.y(); + double cz = vehicle.getZ() + wc.z; + Vec3 normal = Vec3.ZERO; + for (int i = 0; i < pairs; i++) { + Probe w1 = probes[i * 2]; + Probe w2 = probes[i * 2 + 1]; + Vec3 v1 = new Vec3(w1.x - cx, w1.y - cy, w1.z - cz); + Vec3 v2 = new Vec3(w2.x - cx, w2.y - cy, w2.z - cz); + Vec3 n = (w1.local.z() >= 0.0 ? v2.cross(v1) : v1.cross(v2)).normalize(); + double weight = w1.onGround || w2.onGround ? Math.abs(w1.local.z() / wheelBase) : 0.0; + normal = normal.add(n.scale(weight)); + } + normal = normal.normalize(); + + Vec3 slide = Vec3.ZERO; + if (normal.y > 0.01 && normal.y < 0.7) { + slide = new Vec3(normal.x / 50.0, 0.0, normal.z / 50.0); + } + + // Into the hull's frame, then read pitch and roll off the normal. + double yaw = Math.toRadians(vehicle.getYRot()); + double c = Math.cos(yaw); + double s = Math.sin(yaw); + double lx = normal.x * c + normal.z * s; + double lz = normal.z * c - normal.x * s; + float pitch = (float) (90.0 - Math.toDegrees(Math.atan2(normal.y, lz))); + float roll = (float) -(90.0 - Math.toDegrees(Math.atan2(normal.y, lx))); + if (normal.lengthSqr() < 1.0E-6) { + pitch = vehicle.getXRot(); + roll = vehicle.roll; + } + + var def = vehicle.definition(); + float pitchStep = def != null ? def.onGroundPitchFactor : 0.0F; + float rollStep = def != null ? def.onGroundRollFactor : 0.0F; + targetPitch = Mth.clamp(pitch, vehicle.getXRot() - pitchStep, vehicle.getXRot() + pitchStep); + targetRoll = Mth.clamp(roll, vehicle.roll - rollStep, vehicle.roll + rollStep); + + // Wheels that fell behind (stuck on a ledge) are pulled back under the hull. + for (Probe probe : probes) { + Vec3 v = vehicle.position().add(new Vec3(AircraftRotation.toWorld(vehicle.getYRot(), targetPitch, targetRoll, + probe.local.x(), probe.local.y(), probe.local.z()))); + boolean pulled = false; + double x = probe.x; + double z = probe.z; + if (x > v.x + MAX_DRIFT || x < v.x - MAX_DRIFT) { + x = Mth.clamp(x, v.x - MAX_DRIFT, v.x + MAX_DRIFT); + pulled = true; + } + if (z > v.z + MAX_DRIFT || z < v.z - MAX_DRIFT) { + z = Mth.clamp(z, v.z - MAX_DRIFT, v.z + MAX_DRIFT); + pulled = true; + } + if (pulled) { + probe.setPos(x, v.y + STEP_HEIGHT / 2.0, z); + } + } + return slide; + } + + /** One wheel: a one block box that falls onto the terrain and climbs steps up to 1.5 high. */ + private final class Probe { + final Vector3 local; + /** In front of the centre of gravity. */ + final boolean front; + double x; + double y; + double z; + boolean onGround; + + Probe(Vector3 local, boolean front) { + this.local = local; + this.front = front; + } + + void setPos(double x, double y, double z) { + this.x = x; + this.y = y; + this.z = z; + } + + private AABB box() { + return new AABB(x - 0.5, y, z - 0.5, x + 0.5, y + 1.0, z + 0.5); + } + + void move(double dx, double dy, double dz) { + AABB box = box(); + Vec3 wanted = new Vec3(dx, dy, dz); + Vec3 moved = collide(wanted, box); + boolean blockedSideways = moved.x != dx || moved.z != dz; + boolean wasGrounded = onGround || (moved.y != dy && dy < 0.0); + if (blockedSideways && wasGrounded) { + // Same step-up rule as the original wheel entity: climb, move, settle back down. + Vec3 up = collide(new Vec3(0.0, STEP_HEIGHT, 0.0), box); + AABB raised = box.move(up); + Vec3 across = collide(new Vec3(dx, 0.0, dz), raised); + AABB shifted = raised.move(across); + Vec3 down = collide(new Vec3(0.0, -STEP_HEIGHT, 0.0), shifted); + Vec3 stepped = up.add(across).add(down); + if (stepped.horizontalDistanceSqr() > moved.horizontalDistanceSqr()) { + moved = stepped; + } + } + x += moved.x; + y += moved.y; + z += moved.z; + onGround = dy < 0.0 && moved.y != dy; + } + + private Vec3 collide(Vec3 movement, AABB box) { + return Entity.collideBoundingBox(vehicle, movement, box, vehicle.level(), List.of()); + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java new file mode 100644 index 0000000..a9d4152 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; + +/** An air to air missile homing on its locked target, {@code MCH_EntityAAMissile}. */ +public class AAMissileEntity extends ProjectileEntity { + private static final double MAX_DISTANCE_SQ = 3422500.0; + + public AAMissileEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (age() > 4 && w != null && !w.disableSmoke) { + trail(w.trajectoryParticle, 3, 7.0F * w.smokeSize * 0.5F); + } + if (level().isClientSide() || w == null || isRemoved()) { + return; + } + Entity t = target(); + if (user() == null || t == null || t.isRemoved()) { + discard(); + return; + } + double d = distanceToSqr(t); + if (d > MAX_DISTANCE_SQ) { + discard(); + } else if (age() > w.rigidityTime) { + // The original compared the squared distance with the plain fuse distance. + if (w.proximityFuseDist >= 0.1F && d < w.proximityFuseDist) { + setPos((t.getX() + getX()) / 2.0, (t.getY() + getY()) / 2.0, (t.getZ() + getZ()) / 2.0); + impact(new Impact(position(), t, null, Direction.UP), 1.0F); + } else { + guideTo(t.getX(), t.getY(), t.getZ()); + } + } + } + + @Override + public String defaultModel() { + return "aamissile"; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java new file mode 100644 index 0000000..e5f46c9 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java @@ -0,0 +1,91 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * An air to surface missile flying to the block the gunner aimed at, {@code MCH_EntityASMissile}. + * Powered ones climb for half a second first; gliding bombs only steer sideways. + */ +public class ASMissileEntity extends ProjectileEntity { + public double targetX; + public double targetY; + public double targetZ; + + public ASMissileEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public float gravity() { + return isBomblet() ? -0.03F : super.gravity(); + } + + @Override + public float gravityInWater() { + return isBomblet() ? -0.03F : super.gravityInWater(); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (w != null && !w.disableSmoke && !isBomblet()) { + trail(w.trajectoryParticle, 3, 10.0F * w.smokeSize * 0.5F); + } + if (w != null && !level().isClientSide() && !isBomblet() && !isRemoved()) { + BlockPos aim = BlockPos.containing(targetX, targetY, targetZ); + if (!level().getBlockState(aim).isAir()) { + double dist = Math.sqrt(distanceToSqr(targetX, targetY, targetZ)); + if (dist < w.proximityFuseDist) { + if (w.bomblet > 0) { + for (int i = 0; i < w.bomblet; i++) { + sprinkleBomblet(); + } + } else { + impact(new Impact(position(), null, blockPosition(), Direction.UP), 1.0F); + } + discard(); + } else if (gravity() == 0.0F) { + double up = age() < 10 ? 20.0 : 0.0; + aimAlong(targetX - getX(), targetY + up - getY(), targetZ - getZ()); + } else { + double dx = targetX - getX(); + double dy = (targetY - getY()) * 0.3; + double dz = targetZ - getZ(); + double d = Math.sqrt(dx * dx + dy * dy + dz * dz); + Vec3 m = getDeltaMovement(); + setDeltaMovement(dx * acceleration / d, m.y, dz * acceleration / d); + } + } + } + faceMotion(); + tickBomblets(); + } + + @Override + protected void sprinkleBomblet() { + WeaponDefinition w = weapon(); + Vec3 m = getDeltaMovement(); + ASMissileEntity child = spawnChild(ModEntities.AS_MISSILE.get(), m.x, m.y, m.z, random.nextInt(360), 0.0F); + if (child == null || w == null) { + return; + } + float spread = w.bombletDiff; + child.setDeltaMovement(m.x * 0.5 + (random.nextFloat() - 0.5F) * spread, + m.y * 0.5 / 2.0 + (random.nextFloat() - 0.5F) * spread / 2.0F, + m.z * 0.5 + (random.nextFloat() - 0.5F) * spread); + child.setBomblet(); + level().addFreshEntity(child); + } + + @Override + public String defaultModel() { + return "asmissile"; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java new file mode 100644 index 0000000..d7031ec --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java @@ -0,0 +1,84 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; + +/** + * An anti tank missile, {@code MCH_EntityATMissile}. In the default mode it flies straight at the + * locked target; in top attack mode it climbs 150 blocks above the shooter and dives. + */ +public class ATMissileEntity extends ProjectileEntity { + private static final double MAX_DISTANCE_SQ = 2250000.0; + + /** 0 direct, 1 top attack. */ + public int guidanceType; + + public ATMissileEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (w != null && !w.disableSmoke && tickCount >= w.trajectoryParticleStartTick) { + trail(w.trajectoryParticle, 3, 5.0F * w.smokeSize * 0.5F); + } + if (!level().isClientSide() && !isRemoved()) { + Entity t = target(); + if (user() != null && t != null && !t.isRemoved() && w != null) { + steer(w, t); + } else { + discard(); + } + } + faceMotion(); + } + + private void steer(WeaponDefinition w, Entity t) { + double x = t.getX() - getX(); + double y = t.getY() - getY(); + double z = t.getZ() - getZ(); + double d = x * x + y * y + z * z; + if (d > MAX_DISTANCE_SQ) { + discard(); + return; + } + if (w.proximityFuseDist >= 0.1F && d < w.proximityFuseDist) { + impact(new Impact(position(), null, blockPosition(), Direction.UP), 1.0F); + return; + } + int rigidity = w.rigidityTime; + if (age() <= rigidity) { + return; + } + float af = age() < rigidity + w.trajectoryParticleStartTick ? 0.5F : 1.0F; + if (guidanceType == 1) { + Entity shooter = user(); + if (age() <= rigidity + 20) { + guideTo(t.getX(), shooter.getY() + 150.0, t.getZ()); + } else if (age() <= rigidity + 30) { + guideTo(t.getX(), shooter.getY(), t.getZ()); + } else { + if (age() == rigidity + 35) { + power = (int) (power * 1.2F); + if (explosionPower > 0) { + explosionPower++; + } + } + guideTo(t.getX(), t.getY(), t.getZ()); + } + } else { + d = Math.sqrt(d); + setDeltaMovement(x * acceleration / d * af, y * acceleration / d * af, z * acceleration / d * af); + } + } + + @Override + public String defaultModel() { + return "asmissile"; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java new file mode 100644 index 0000000..c758d75 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java @@ -0,0 +1,68 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** A free fall bomb, {@code MCH_EntityBomb}. Cluster bombs split into bomblets. */ +public class BombEntity extends ProjectileEntity { + public BombEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (!level().isClientSide() && w != null && !isRemoved()) { + Vec3 m = getDeltaMovement(); + m = new Vec3(m.x * 0.999, m.y, m.z * 0.999); + if (isInWater()) { + m = m.scale(w.velocityInWater); + } + setDeltaMovement(m); + float fuse = w.proximityFuseDist; + if (fuse > 0.1F && age() % 10 == 0) { + for (Entity entity : level().getEntities(this, getBoundingBox().inflate(fuse), this::canHit)) { + if (entity instanceof LivingEntity) { + impact(new Impact(position(), null, blockPosition(), Direction.UP), 1.0F); + break; + } + } + } + } + tickBomblets(); + } + + @Override + protected void sprinkleBomblet() { + WeaponDefinition w = weapon(); + Vec3 m = getDeltaMovement(); + BombEntity child = spawnChild(ModEntities.BOMB.get(), m.x, m.y, m.z, random.nextInt(360), 0.0F); + if (child == null || w == null) { + return; + } + float spread = w.bombletDiff; + child.setDeltaMovement(m.x + (random.nextFloat() - 0.5F) * spread, + m.y / 2.0 + (random.nextFloat() - 0.5F) * spread / 2.0F, + m.z + (random.nextFloat() - 0.5F) * spread); + child.setBomblet(); + level().addFreshEntity(child); + } + + /** A cluster bomb that has not split yet is not drawn when it splits right away. */ + public boolean isVisible() { + WeaponDefinition w = weapon(); + return w == null || isBomblet() || w.bomblet <= 0 || w.bombletSTime > 0; + } + + @Override + public String defaultModel() { + return "bomb"; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java new file mode 100644 index 0000000..b2b418a --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java @@ -0,0 +1,45 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; + +/** A gun round, {@code MCH_EntityBullet}. High explosive rounds may carry a proximity fuse. */ +public class BulletEntity extends ProjectileEntity { + public BulletEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected boolean boostsAcceleration() { + return true; + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (isRemoved() || level().isClientSide() || age() <= 1 || w == null || explosionPower <= 0) { + return; + } + float fuse = w.proximityFuseDist; + if (fuse > 0.1F) { + fuse++; + float range = fuse + Math.abs(w.acceleration); + for (Entity entity : level().getEntities(this, getBoundingBox().inflate(range), this::canHit)) { + if (entity.distanceToSqr(this) < fuse * fuse) { + setPos((entity.getX() + getX()) / 2.0, (entity.getY() + getY()) / 2.0, (entity.getZ() + getZ()) / 2.0); + impact(new Impact(position(), null, blockPosition(), Direction.UP), 1.0F); + break; + } + } + } + } + + @Override + public String defaultModel() { + return "bullet"; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java new file mode 100644 index 0000000..c39fa6f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java @@ -0,0 +1,49 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** An unguided rocket, {@code MCH_EntityRocket}, which may burst into bomblets. */ +public class RocketEntity extends ProjectileEntity { + public RocketEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected boolean boostsAcceleration() { + return !isBomblet(); + } + + @Override + public void tick() { + super.tick(); + tickBomblets(); + WeaponDefinition w = weapon(); + if (!isBomblet() && w != null && !w.disableSmoke) { + trail(w.trajectoryParticle, 3, 5.0F * w.smokeSize * 0.5F); + } + } + + @Override + protected void sprinkleBomblet() { + WeaponDefinition w = weapon(); + Vec3 m = getDeltaMovement(); + RocketEntity child = spawnChild(ModEntities.ROCKET.get(), m.x, m.y, m.z, getYRot(), getXRot()); + if (child == null || w == null) { + return; + } + float spread = w.bombletDiff; + child.setDeltaMovement(child.getDeltaMovement().add( + (random.nextFloat() - 0.5) * spread, (random.nextFloat() - 0.5) * spread, (random.nextFloat() - 0.5) * spread)); + child.setBomblet(); + level().addFreshEntity(child); + } + + @Override + public String defaultModel() { + return "rocket"; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java new file mode 100644 index 0000000..a89f7c0 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java @@ -0,0 +1,56 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * A torpedo, {@code MCH_EntityTorpedo}. Once in the water it settles on its running speed and + * either holds course or, when guided, runs to the point the gunner aimed at. + */ +public class TorpedoEntity extends ProjectileEntity { + public double targetX; + public double targetY; + public double targetZ; + public double accelerationInWater = 2.0; + + public TorpedoEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + boolean guided = w != null && w.guidedTorpedo; + if (!level().isClientSide() && isInWater()) { + if (acceleration < accelerationInWater) { + acceleration += 0.1; + } else if (acceleration > accelerationInWater + 0.2F) { + acceleration -= 0.1; + } + if (guided) { + aimAlong(targetX - getX(), targetY - getY(), targetZ - getZ()); + } else { + Vec3 m = getDeltaMovement(); + aimAlong(m.x, m.y * 0.8F, m.z); + } + } + if (isInWater()) { + Vec3 m = getDeltaMovement(); + setYRot((float) Math.toDegrees(Math.atan2(m.z, m.x)) - 90.0F); + if (guided) { + setXRot(-(float) Math.toDegrees(Math.atan2(m.y, Math.sqrt(m.x * m.x + m.z * m.z)))); + } + if (w != null && !w.disableSmoke) { + trail(w.trajectoryParticle, 3, 5.0F * w.smokeSize * 0.5F); + } + } + } + + @Override + public String defaultModel() { + return "torpedo"; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java new file mode 100644 index 0000000..a06fef1 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java @@ -0,0 +1,55 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * A wire guided missile flown by the gunner's view, {@code MCH_EntityTvMissile}. It steers while + * it is the vehicle's active TV missile; once another is fired it carries on straight. + */ +public class TvMissileEntity extends ProjectileEntity { + private static final double MAX_DISTANCE_SQ = 1440000.0; + + public TvMissileEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + public void tick() { + super.tick(); + WeaponDefinition w = weapon(); + if (w != null && !w.disableSmoke) { + trail(w.trajectoryParticle, 3, 5.0F * w.smokeSize * 0.5F); + } + Entity aimer = user(); + if (aimer != null) { + if (distanceToSqr(aimer) > MAX_DISTANCE_SQ) { + discard(); + } + if (!level().isClientSide() && !isRemoved()) { + steer(aimer); + } + } else if (!level().isClientSide()) { + discard(); + } + } + + private void steer(Entity aimer) { + if (aimer.isRemoved() || !(shooter() instanceof AircraftEntity vehicle) || vehicle.tvMissile() != this) { + return; + } + Vec3 dir = Vec3.directionFromRotation(aimer.getXRot(), aimer.getYRot()); + aimAlong(dir.x, dir.y, dir.z); + setYRot(aimer.getYRot()); + setXRot(aimer.getXRot()); + } + + @Override + public String defaultModel() { + return "asmissile"; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 0000000..c261904 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java @@ -0,0 +1,68 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemStackTemplate; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * Matches one vehicle, such as an AH-6 in the recipe that turns it into an AH-6X. Every vehicle of + * a kind shares an item, so a plain item ingredient would accept any of them. In JSON: + *

{"type": "mcheli:vehicle", "vehicle": "ah-6"}
+ * with the type also under {@code neoforge:ingredient_type} and {@code fabric:type}, since each + * loader names custom ingredients its own way. + * + *

This is the loader-independent half; each loader module wraps it in its own ingredient class. + */ +public final class VehicleIngredient { + public static final Identifier ID = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "vehicle"); + + private VehicleIngredient() { + } + + /** The JSON fields, given a way to build the loader's ingredient from a vehicle name. */ + public static MapCodec codec(Function create, Function vehicle) { + return Codec.STRING.fieldOf("vehicle").xmap(create, vehicle); + } + + public static StreamCodec streamCodec(Function create, Function vehicle) { + return ByteBufCodecs.STRING_UTF8.map(create, vehicle).cast(); + } + + /** The items a vehicle can be: the generic item of each kind. */ + @SuppressWarnings("deprecation") + public static HolderSet kindItems() { + return HolderSet.direct(Item::builtInRegistryHolder, + ModItems.HELICOPTER.get(), ModItems.PLANE.get(), ModItems.TANK.get(), ModItems.VEHICLE.get()); + } + + public static Stream> items() { + return kindItems().stream(); + } + + public static boolean matches(String vehicle, @Nullable ItemStack stack) { + return stack != null && vehicle.equals(AircraftItem.vehicleOf(stack)); + } + + /** What recipe books show: the vehicle itself, or nothing while it is not loaded. */ + public static SlotDisplay display(String vehicle) { + AircraftDefinition def = MCHeli.content().any(vehicle); + return def != null ? new SlotDisplay.ItemStackSlotDisplay(ItemStackTemplate.fromNonEmptyStack(AircraftItem.stackOf(def))) + : SlotDisplay.Empty.INSTANCE; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java similarity index 73% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java index 0bb9a08..ba56ca3 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java @@ -2,10 +2,12 @@ import com.jnhprojects.mcheli.entity.AircraftEntity; 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; import net.minecraft.world.InteractionHand; import net.minecraft.world.phys.Vec3; -import net.minecraftforge.event.network.CustomPayloadEvent; /** * Client to server: the player right-clicked a vehicle's hull outside its main hit box. The @@ -13,7 +15,10 @@ * * @param location where on the hull the click landed, in world coordinates */ -public record BoardVehiclePacket(int entityId, Vec3 location) { +public record BoardVehiclePacket(int entityId, Vec3 location) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("board_vehicle"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(BoardVehiclePacket::encode, BoardVehiclePacket::decode); + /** The same slack vanilla allows on top of the player's reach for a click on an entity. */ private static final double REACH_SLACK = 3.0; @@ -28,8 +33,7 @@ public static BoardVehiclePacket decode(FriendlyByteBuf buf) { return new BoardVehiclePacket(buf.readVarInt(), new Vec3(buf.readDouble(), buf.readDouble(), buf.readDouble())); } - public static void handle(BoardVehiclePacket msg, CustomPayloadEvent.Context ctx) { - ServerPlayer sender = ctx.getSender(); + public static void handle(BoardVehiclePacket msg, ServerPlayer sender) { if (sender == null || !(sender.level().getEntity(msg.entityId) instanceof AircraftEntity vehicle) || vehicle.isRemoved()) { return; } @@ -46,4 +50,9 @@ public static void handle(BoardVehiclePacket msg, CustomPayloadEvent.Context ctx vehicle.interact(sender, InteractionHand.MAIN_HAND, msg.location.subtract(vehicle.position())); } } + + @Override + public Type type() { + return TYPE; + } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java new file mode 100644 index 0000000..8e7136f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -0,0 +1,142 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +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; + +/** + * A one-off request from a crew member: lower the gear, fold the wings, change seats and so on. + * + *

The client only asks; the server checks that the sender sits in that vehicle, in a seat + * allowed to do it, and then applies the same conditions the original checked before switching. + * The result reaches every client through synched data. + */ +public record CrewActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("crew_action"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(CrewActionPacket::encode, CrewActionPacket::decode); + + public enum Action { + /** Raise or lower the landing gear. Pilot only. */ + LANDING_GEAR, + /** Fold or unfold hatches, wings or swept wings. Pilot only. */ + FOLD, + /** Tilt the nozzles for vertical flight. Pilot only. */ + VTOL, + /** Eject from seat zero or one. */ + EJECT, + /** Move to the next free crew seat. */ + SWITCH_SEAT, + /** Load nearby cargo onto the racks, or drive onto a carrier's rack. Pilot only. */ + RACK_LOAD, + /** Drop the last cargo, or leave the rack this vehicle rides on. Pilot only. */ + RACK_UNLOAD, + /** Select the next weapon this seat may use. */ + NEXT_WEAPON, + /** Select the previous weapon this seat may use. */ + PREVIOUS_WEAPON, + /** Cycle the fire mode of the selected weapon. */ + WEAPON_MODE, + /** Release flares. Pilot and first crew seat. */ + FLARE, + /** Pay the selected weapon's {@code Item} cost from the inventory for one resupply. */ + RESUPPLY, + /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ + GUNNER_MODE + } + + public static void encode(CrewActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static CrewActionPacket decode(FriendlyByteBuf buf) { + int entityId = buf.readVarInt(); + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown crew action " + ordinal); + } + return new CrewActionPacket(entityId, actions[ordinal]); + } + + public static void handle(CrewActionPacket msg, ServerPlayer sender) { + if (sender == null || !(sender.getVehicle() instanceof AircraftEntity vehicle) || vehicle.getId() != msg.entityId) { + return; + } + boolean pilot = vehicle.getControllingPassenger() == sender; + switch (msg.action) { + case LANDING_GEAR -> { + if (pilot) { + vehicle.toggleLandingGear(); + } + } + case FOLD -> { + if (pilot) { + vehicle.toggleFold(); + } + } + case VTOL -> { + if (pilot && vehicle instanceof PlaneEntity plane) { + plane.toggleVtol(); + } + } + case GUNNER_MODE -> { + if (pilot) { + vehicle.setGunnerMode(!vehicle.isGunnerMode()); + } + } + case EJECT -> vehicle.eject(sender); + case SWITCH_SEAT -> vehicle.switchSeat(sender); + case RACK_LOAD -> { + if (pilot) { + if (!vehicle.boardNearbyRack()) { + vehicle.loadNearbyCargo(); + } + } + } + case RACK_UNLOAD -> { + if (pilot) { + if (vehicle.isPassenger()) { + vehicle.leaveRack(); + } else { + vehicle.unloadCargo(); + } + } + } + case NEXT_WEAPON, PREVIOUS_WEAPON -> { + VehicleWeapons weapons = vehicle.weapons(); + int seat = vehicle.seatOf(sender); + if (weapons != null && !vehicle.isDestroyed()) { + int next = weapons.next(seat, msg.action == Action.NEXT_WEAPON ? 1 : -1); + if (next >= 0) { + weapons.switchTo(sender, seat, next); + } + } + } + case WEAPON_MODE -> { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons != null) { + weapons.switchMode(vehicle.seatOf(sender)); + } + } + case FLARE -> { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons != null && vehicle.seatOf(sender) <= 1) { + weapons.flares.use(); + } + } + case RESUPPLY -> Resupply.fromInventory(sender, vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java new file mode 100644 index 0000000..279b151 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java @@ -0,0 +1,54 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.weapon.ExplosionEffect; +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.world.entity.Entity; + +/** + * Server to nearby players: draw an MCHeli explosion here. The original's + * {@code MCH_PacketEffectExplosion}, plus a flag for the bigger fireball of a crashing vehicle. + */ +public record ExplosionEffectPacket(double x, double y, double z, float size, boolean inWater, boolean crash) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("explosion_effect"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(ExplosionEffectPacket::encode, ExplosionEffectPacket::decode); + + public ExplosionEffectPacket(double x, double y, double z, float size, boolean inWater) { + this(x, y, z, size, inWater, false); + } + + public static void encode(ExplosionEffectPacket msg, FriendlyByteBuf buf) { + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.size); + buf.writeBoolean(msg.inWater); + buf.writeBoolean(msg.crash); + } + + public static ExplosionEffectPacket decode(FriendlyByteBuf buf) { + return new ExplosionEffectPacket(buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readBoolean(), + buf.readBoolean()); + } + + public static void handle(ExplosionEffectPacket msg) { + Entity player = ClientAccess.localPlayer(); + if (player == null || !Float.isFinite(msg.size)) { + return; + } + float size = Math.min(msg.size, 50.0F); + if (msg.crash) { + ExplosionEffect.crash(player.level(), msg.x, msg.y, msg.z, Math.min(size, ExplosionEffect.MAX_CRASH_SIZE)); + } else { + ExplosionEffect.play(player.level(), msg.x, msg.y, msg.z, size, msg.inWater); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java new file mode 100644 index 0000000..0056dcc --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java @@ -0,0 +1,54 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.weapon.ServerFiring; +import com.jnhprojects.mcheli.weapon.Shot; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +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; + +/** + * Client to server: the crew member's selected weapon fired on their side, so fire it for real. + * The client already checked readiness, ammunition and lock; the server checks again with its own + * timers and only trusts the two option values, a locked entity and a fire mode. + * + * @param weapon the weapon the client fired, ignored unless it is still the seat's selection + */ +public record FireWeaponPacket(int entityId, int weapon, int option1, int option2) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("fire_weapon"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(FireWeaponPacket::encode, FireWeaponPacket::decode); + + public static void encode(FireWeaponPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.weapon); + buf.writeVarInt(msg.option1); + buf.writeVarInt(msg.option2); + } + + public static FireWeaponPacket decode(FriendlyByteBuf buf) { + return new FireWeaponPacket(buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(FireWeaponPacket msg, ServerPlayer sender) { + if (sender == null || !(sender.getVehicle() instanceof AircraftEntity vehicle) || vehicle.getId() != msg.entityId) { + return; + } + VehicleWeapons weapons = vehicle.weapons(); + int seat = vehicle.seatOf(sender); + if (weapons == null || weapons.selected(seat) != msg.weapon || weapons.operatorSeat(msg.weapon) != seat) { + return; + } + Shot shot = new Shot(vehicle, sender); + shot.option1 = msg.option1; + shot.option2 = msg.option2; + weapons.use(sender, seat, shot, ServerFiring.INSTANCE); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java new file mode 100644 index 0000000..97d2cbf --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java @@ -0,0 +1,44 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +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; +import net.minecraft.world.entity.Entity; + +/** + * Client to server: this player is locking on to an entity. If it is a vehicle with flares, or a + * crew member of one, its crew hears the warning. The original's {@code MCH_PacketNotifyLock}. + */ +public record LockWarningPacket(int targetId) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("lock_warning"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LockWarningPacket::encode, LockWarningPacket::decode); + + public static void encode(LockWarningPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.targetId); + } + + public static LockWarningPacket decode(FriendlyByteBuf buf) { + return new LockWarningPacket(buf.readVarInt()); + } + + public static void handle(LockWarningPacket msg, ServerPlayer sender) { + if (sender == null || !(sender.getVehicle() instanceof AircraftEntity)) { + return; + } + Entity target = sender.level().getEntity(msg.targetId); + if (target != null && target.distanceToSqr(sender) < 400.0 * 400.0) { + AircraftEntity vehicle = target instanceof AircraftEntity a ? a : target.getVehicle() instanceof AircraftEntity a ? a : null; + if (vehicle != null) { + vehicle.warnLocked(); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} 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 new file mode 100644 index 0000000..464780e --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,81 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Our packets. Each one is a custom payload with its own id and the same encoding on every loader, + * so a client on one loader can talk to a server on another. The loader module registers the + * lists below with its own networking API; handlers always run on the main thread. + */ +public final class ModNetwork { + /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ + public static final int PROTOCOL_VERSION = 6; + + /** Client to server. The handler gets the sending player. */ + public static final List> SERVERBOUND = List.of( + new Serverbound<>(PilotStatePacket.TYPE, PilotStatePacket.STREAM_CODEC, PilotStatePacket::handle), + new Serverbound<>(CrewActionPacket.TYPE, CrewActionPacket.STREAM_CODEC, CrewActionPacket::handle), + 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)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + new Clientbound<>(WeaponStatePacket.TYPE, WeaponStatePacket.STREAM_CODEC, WeaponStatePacket::handle), + 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)); + + private ModNetwork() { + } + + static CustomPacketPayload.Type type(String name) { + return new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, name)); + } + + public static void sendToServer(CustomPacketPayload message) { + Platform.get().sendToServer(message); + } + + public static void sendToPlayer(ServerPlayer player, CustomPacketPayload message) { + Platform.get().sendToPlayer(player, message); + } + + public static void sendToAll(MinecraftServer server, CustomPacketPayload message) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + sendToPlayer(player, message); + } + } + + /** Sends to every player in {@code level} within {@code range} blocks of the point. */ + public static void sendNear(ServerLevel level, double x, double y, double z, double range, CustomPacketPayload message) { + for (ServerPlayer player : level.players()) { + if (player.distanceToSqr(x, y, z) < range * range) { + sendToPlayer(player, message); + } + } + } + + public record Serverbound(CustomPacketPayload.Type type, + StreamCodec codec, + BiConsumer handler) { + } + + public record Clientbound(CustomPacketPayload.Type type, + StreamCodec codec, + Consumer handler) { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 0000000..89c1644 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.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: one chunk of a pack's zip. Chunks of a pack arrive in order, so + * {@code offset} only lets the client check that nothing went missing. + * + * @param total length of the whole zip, as in the {@link PackListPacket} + */ +public record PackDataPacket(String hash, int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_data"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackDataPacket::encode, PackDataPacket::decode); + + /** Well under the 1 MiB limit of a clientbound custom payload. */ + public static final int CHUNK_SIZE = 128 * 1024; + + public static void encode(PackDataPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.hash, 64); + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackDataPacket decode(FriendlyByteBuf buf) { + return new PackDataPacket(buf.readUtf(64), buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackDataPacket msg) { + ClientAccess.packs().onPackData(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 0000000..ad7f0c8 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import io.netty.handler.codec.DecoderException; +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 java.util.ArrayList; +import java.util.List; + +/** + * Server to player: the content packs the server uses, in load order. Sent on join and after + * {@code /mcheli reload}. The client requests the packs it has not cached ({@link PackRequestPacket}) + * and then rebuilds its content from exactly this list, so both sides fly the same definitions. + * The imported 1.0.3 content is never listed: every player imports their own copy. + */ +public record PackListPacket(List packs) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_list"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackListPacket::encode, PackListPacket::decode); + + public static final int MAX_PACKS = 256; + + /** + * @param size length of the pack's zip in bytes, which is what {@link PackDataPacket} sends + */ + public record Entry(String id, String version, String hash, int size) { + } + + public static void encode(PackListPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.packs.size()); + for (Entry entry : msg.packs) { + buf.writeUtf(entry.id, 64); + buf.writeUtf(entry.version, 32); + buf.writeUtf(entry.hash, 64); + buf.writeVarInt(entry.size); + } + } + + public static PackListPacket decode(FriendlyByteBuf buf) { + int count = buf.readVarInt(); + if (count < 0 || count > MAX_PACKS) { + throw new DecoderException("Too many content packs: " + count); + } + List packs = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + packs.add(new Entry(buf.readUtf(64), buf.readUtf(32), buf.readUtf(64), buf.readVarInt())); + } + return new PackListPacket(List.copyOf(packs)); + } + + public static void handle(PackListPacket msg) { + ClientAccess.packs().onPackList(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 0000000..50f10f7 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.network; + +import io.netty.handler.codec.DecoderException; +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * Player to server: send me these packs, by content hash. The server only answers for hashes in + * the list it sent, so a client cannot ask for anything else. + */ +public record PackRequestPacket(List hashes) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_request"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackRequestPacket::encode, PackRequestPacket::decode); + + public static void encode(PackRequestPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.hashes.size()); + for (String hash : msg.hashes) { + buf.writeUtf(hash, 64); + } + } + + public static PackRequestPacket decode(FriendlyByteBuf buf) { + int count = buf.readVarInt(); + if (count < 0 || count > PackListPacket.MAX_PACKS) { + throw new DecoderException("Too many content packs requested: " + count); + } + List hashes = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + hashes.add(buf.readUtf(64)); + } + return new PackRequestPacket(List.copyOf(hashes)); + } + + public static void handle(PackRequestPacket msg, ServerPlayer sender) { + if (sender != null) { + PackSync.request(sender, msg.hashes); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java similarity index 88% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java index fc462d7..fe9c668 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -5,8 +5,6 @@ import net.minecraft.commands.Commands; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; -import net.minecraftforge.event.TickEvent; -import net.minecraftforge.event.entity.player.PlayerEvent; import org.jspecify.annotations.Nullable; import java.io.IOException; @@ -39,17 +37,16 @@ public final class PackSync { private PackSync() { } - public static void init() { - PlayerEvent.PlayerLoggedInEvent.BUS.addListener(event -> { - if (event.getEntity() instanceof ServerPlayer player) { - ModNetwork.sendToPlayer(player, list()); - if (seesProblems(player)) { - MCHeli.packs().reportProblems(player::sendSystemMessage); - } - } - }); - PlayerEvent.PlayerLoggedOutEvent.BUS.addListener(event -> TRANSFERS.remove(event.getEntity().getUUID())); - TickEvent.ServerTickEvent.Post.BUS.addListener(event -> pump(event.server())); + /** Sends a joining player the pack list, and operators any pack problems. */ + public static void onLogin(ServerPlayer player) { + ModNetwork.sendToPlayer(player, list()); + if (seesProblems(player)) { + MCHeli.packs().reportProblems(player::sendSystemMessage); + } + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); } /** The packs the server uses now, as the client should load them. */ @@ -101,7 +98,7 @@ static void request(ServerPlayer player, List hashes) { return null; } - private static void pump(MinecraftServer server) { + public static void pump(MinecraftServer server) { Iterator>> it = TRANSFERS.entrySet().iterator(); while (it.hasNext()) { Map.Entry> entry = it.next(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java new file mode 100644 index 0000000..6426f06 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +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; + +/** + * Sent by the pilot every tick. Position, yaw and pitch already travel through the vanilla + * vehicle move packet; this carries the parts vanilla does not know about. + * + * @param keys the pilot's key state as packed by {@link AircraftEntity.ControlInput#write()}, so + * other players see wheels steer and tracks turn + */ +public record PilotStatePacket(int entityId, float throttle, float reverse, float roll, boolean hovering, byte keys) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pilot_state"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PilotStatePacket::encode, PilotStatePacket::decode); + + public static void encode(PilotStatePacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeFloat(msg.throttle); + buf.writeFloat(msg.reverse); + buf.writeFloat(msg.roll); + buf.writeBoolean(msg.hovering); + buf.writeByte(msg.keys); + } + + public static PilotStatePacket decode(FriendlyByteBuf buf) { + return new PilotStatePacket(buf.readVarInt(), buf.readFloat(), buf.readFloat(), buf.readFloat(), buf.readBoolean(), buf.readByte()); + } + + public static void handle(PilotStatePacket msg, ServerPlayer sender) { + if (sender == null) { + return; + } + // Only the player actually flying this aircraft may drive it. + if (sender.getVehicle() instanceof AircraftEntity aircraft + && aircraft.getId() == msg.entityId + && aircraft.getControllingPassenger() == sender + && Float.isFinite(msg.throttle) + && Float.isFinite(msg.reverse) + && Float.isFinite(msg.roll)) { + aircraft.acceptPilotState(msg.throttle, msg.reverse, msg.roll, msg.hovering, msg.keys); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java new file mode 100644 index 0000000..292f643 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java @@ -0,0 +1,45 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +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.world.entity.Entity; + +/** + * Server to crew: the magazine and reserve of one weapon after it fired, reloaded or was + * resupplied. The original's {@code MCH_PacketNotifyAmmoNum}. + */ +public record WeaponStatePacket(int entityId, int weapon, int ammo, int rest) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("weapon_state"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(WeaponStatePacket::encode, WeaponStatePacket::decode); + + public static void encode(WeaponStatePacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.weapon); + buf.writeVarInt(msg.ammo); + buf.writeVarInt(msg.rest); + } + + public static WeaponStatePacket decode(FriendlyByteBuf buf) { + return new WeaponStatePacket(buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(WeaponStatePacket msg) { + Entity player = ClientAccess.localPlayer(); + if (player != null && player.level().getEntity(msg.entityId) instanceof AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons != null) { + weapons.applyAmmo(msg.weapon, msg.ammo, msg.rest); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 0000000..e951f49 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Platform.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * What the shared code needs from the mod loader. Each loader module installs its implementation + * first thing during mod construction, before anything else of ours runs. + * + *

Kept small on purpose: registration and events go the other way, with the loader module + * calling into the shared code (see {@code Registration} and the {@code *Hooks} classes). + */ +public interface Platform { + + static Platform get() { + Platform platform = Holder.instance; + if (platform == null) { + throw new IllegalStateException("No mod loader platform installed yet"); + } + return platform; + } + + static void install(Platform platform) { + if (Holder.instance != null) { + throw new IllegalStateException("Platform already installed: " + Holder.instance.loaderName()); + } + Holder.instance = Objects.requireNonNull(platform); + } + + /** {@code forge}, {@code neoforge} or {@code fabric}, for logs and {@code /mcheli status}. */ + String loaderName(); + + /** The game directory, which holds the {@code mcheli} folder. */ + Path gameDir(); + + /** The config directory, which holds the client config. */ + Path configDir(); + + /** Our version as the loader read it from the mod metadata. */ + String modVersion(); + + /** Whether this is a physical client, as opposed to a dedicated server. */ + boolean isClient(); + + void sendToServer(CustomPacketPayload payload); + + void sendToPlayer(ServerPlayer player, CustomPacketPayload payload); + + /** + * A builder for one of our creative tabs, placed after {@code after}. Vanilla's builder is not + * public on every loader and only Forge and NeoForge can order tabs. + */ + CreativeModeTab.Builder creativeTab(ResourceKey after); + + final class Holder { + private static volatile Platform instance; + + private Holder() { + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 0000000..3d1b908 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Registered.java @@ -0,0 +1,35 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.resources.Identifier; + +import java.util.function.Supplier; + +/** + * A registry entry the shared code declares and the loader module registers, like Forge's + * {@code RegistryObject}. {@link #get()} works once the loader has registered it. + */ +public final class Registered implements Supplier { + private final Identifier id; + private volatile T value; + + Registered(Identifier id) { + this.id = id; + } + + public Identifier id() { + return id; + } + + @Override + public T get() { + T v = value; + if (v == null) { + throw new IllegalStateException("Registry entry " + id + " is not registered yet"); + } + return v; + } + + void bind(T value) { + this.value = value; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 0000000..030a07d --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/platform/Registration.java @@ -0,0 +1,80 @@ +package com.jnhprojects.mcheli.platform; + +import com.jnhprojects.mcheli.MCHeli; +import net.minecraft.core.Registry; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Every vanilla registry entry the mod adds. The shared registry classes declare their entries + * here during mod construction; the loader module then registers them its own way: Forge and + * NeoForge from their register event, Fabric straight away. + */ +public final class Registration { + private static final List> PENDING = new ArrayList<>(); + private static final Set>> REGISTRIES = new LinkedHashSet<>(); + + private Registration() { + } + + /** + * Declares an entry. + * + * @param factory makes the value; it gets the entry's key for {@code Item.Properties#setId} + * and the like + */ + public static synchronized Registered register(ResourceKey> registry, String name, + Function, T> factory) { + Identifier id = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, name); + Registered entry = new Registered<>(id); + PENDING.add(new Pending<>(registry, entry, factory)); + REGISTRIES.add(registry); + return entry; + } + + /** The registries with entries of ours, in the order they were first used. */ + public static synchronized List>> registries() { + return List.copyOf(REGISTRIES); + } + + /** + * A key from {@link #registries()} with its element type filled in, for the loader's generic + * register calls. The type is whatever the caller needs; entries are only ever handed to the + * registry they were declared for. + */ + @SuppressWarnings("unchecked") + public static ResourceKey> typed(ResourceKey> registry) { + return (ResourceKey>) registry; + } + + /** + * Creates our entries for one registry, in declaration order, and hands each to the loader. + * Called once per registry. + */ + public static synchronized void registerAll(ResourceKey> registry, BiConsumer sink) { + for (Pending pending : PENDING) { + if (pending.registry.equals(registry)) { + @SuppressWarnings("unchecked") + Pending ours = (Pending) pending; + ours.create(sink); + } + } + } + + private record Pending(ResourceKey> registry, Registered entry, + Function, T> factory) { + @SuppressWarnings("unchecked") + void create(BiConsumer sink) { + T value = factory.apply(ResourceKey.create((ResourceKey>) registry, entry.id())); + entry.bind(value); + sink.accept(entry.id(), value); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java new file mode 100644 index 0000000..8b24af0 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.CreativeModeTabs; +import net.minecraft.world.item.ItemStack; + +import java.util.List; + +/** + * One tab per vehicle kind, listing a stack for each loaded definition of that kind. The list is + * read from the content registry whenever a tab is built, so reloaded content shows up. + */ +public final class ModCreativeTabs { + public static final Registered HELICOPTERS = register("helicopters", "helicopter", "ah-64", + CreativeModeTabs.SPAWN_EGGS); + public static final Registered PLANES = register("planes", "plane", "f-35b", key(HELICOPTERS)); + public static final Registered TANKS = register("tanks", "tank", "m1a2", key(PLANES)); + public static final Registered VEHICLES = register("vehicles", "vehicle", "mk15", key(TANKS)); + + private ModCreativeTabs() { + } + + private static ResourceKey key(Registered tab) { + return ResourceKey.create(Registries.CREATIVE_MODE_TAB, tab.id()); + } + + private static Registered register(String name, String kind, String icon, ResourceKey after) { + return Registration.register(Registries.CREATIVE_MODE_TAB, name, key -> Platform.get().creativeTab(after) + .title(Component.translatable("itemGroup.mcheli." + name)) + .icon(() -> { + AircraftDefinition def = MCHeli.content().any(icon); + return def != null ? AircraftItem.stackOf(def) : new ItemStack(ModItems.forKind(kind)); + }) + .displayItems((params, output) -> { + for (AircraftDefinition def : MCHeli.content().all()) { + if (def.kind().equals(kind)) { + output.accept(AircraftItem.stackOf(def)); + } + } + }) + .build()); + } + + /** Our tabs, for rebuilding their contents after a content reload. */ + public static List all() { + return List.of(HELICOPTERS.get(), PLANES.get(), TANKS.get(), VEHICLES.get()); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 0000000..08b564e --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import com.mojang.serialization.Codec; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.codec.ByteBufCodecs; + +public final class ModDataComponents { + /** + * The definition a vehicle item places, such as {@code ah-64}. Saved with the stack and sent + * to clients; the item itself only says which kind of vehicle it is. + */ + public static final Registered> VEHICLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "vehicle", + key -> DataComponentType.builder() + .persistent(Codec.STRING) + .networkSynchronized(ByteBufCodecs.STRING_UTF8) + .build()); + + private ModDataComponents() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 0000000..c16131e --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,108 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.BombEntity; +import com.jnhprojects.mcheli.entity.projectile.BulletEntity; +import com.jnhprojects.mcheli.entity.projectile.DispensedItemEntity; +import com.jnhprojects.mcheli.entity.projectile.FlareEntity; +import com.jnhprojects.mcheli.entity.projectile.MarkerRocketEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.entity.projectile.RocketEntity; +import com.jnhprojects.mcheli.entity.projectile.TorpedoEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; + +/** + * One entity type per vehicle kind. The concrete vehicle is stored on the entity, the same approach + * the original took, so adding content never changes the entity registry. + */ +public final class ModEntities { + public static final Registered> HELICOPTER = register("helicopter", HelicopterEntity::new); + public static final Registered> PLANE = register("plane", PlaneEntity::new); + public static final Registered> TANK = register("tank", TankEntity::new); + public static final Registered> VEHICLE = register("vehicle", GroundVehicleEntity::new); + + public static final Registered> BULLET = projectile("bullet", BulletEntity::new); + public static final Registered> ROCKET = projectile("rocket", RocketEntity::new); + public static final Registered> BOMB = projectile("bomb", BombEntity::new); + public static final Registered> AA_MISSILE = projectile("aa_missile", AAMissileEntity::new); + public static final Registered> AS_MISSILE = projectile("as_missile", ASMissileEntity::new); + public static final Registered> AT_MISSILE = projectile("at_missile", ATMissileEntity::new); + public static final Registered> TV_MISSILE = projectile("tv_missile", TvMissileEntity::new); + public static final Registered> TORPEDO = projectile("torpedo", TorpedoEntity::new); + public static final Registered> MARKER_ROCKET = projectile("marker_rocket", MarkerRocketEntity::new); + public static final Registered> DISPENSED_ITEM = projectile("dispensed_item", DispensedItemEntity::new); + public static final Registered> FLARE = Registration.register(Registries.ENTITY_TYPE, "flare", key -> EntityType.Builder + .of(FlareEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(16) + .updateInterval(2) + .fireImmune() + .noSave() + .build(key)); + public static final Registered> A10 = Registration.register(Registries.ENTITY_TYPE, "a10", key -> EntityType.Builder + .of(A10Entity::new, MobCategory.MISC) + .sized(5.0F, 3.0F) + .clientTrackingRange(32) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + + private ModEntities() { + } + + /** Projectiles are a metre cube like the original's, and never saved. */ + private static Registered> projectile(String name, EntityType.EntityFactory factory) { + return Registration.register(Registries.ENTITY_TYPE, name, key -> EntityType.Builder.of(factory, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(16) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + } + + private static Registered> register(String name, EntityType.EntityFactory factory) { + return Registration.register(Registries.ENTITY_TYPE, name, key -> EntityType.Builder.of(factory, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(16) + .updateInterval(1) + .fireImmune() + .build(key)); + } + + /** The entity type that hosts a definition of this kind. */ + public static EntityType typeFor(AircraftDefinition def) { + if (def instanceof HelicopterDefinition) { + return HELICOPTER.get(); + } + if (def instanceof PlaneDefinition) { + return PLANE.get(); + } + if (def instanceof TankDefinition) { + return TANK.get(); + } + return VEHICLE.get(); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 0000000..5292745 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.Item; + +import java.util.Map; + +/** + * One item per vehicle kind; the stack says which vehicle (see {@link AircraftItem}). The 1.0.0 + * items, one per vehicle, are kept for the content present at startup so old worlds keep them. + */ +public final class ModItems { + public static final Registered HELICOPTER = kind("helicopter"); + public static final Registered PLANE = kind("plane"); + public static final Registered TANK = kind("tank"); + public static final Registered VEHICLE = kind("vehicle"); + + private static final Map> BY_KIND = + Map.of("helicopter", HELICOPTER, "plane", PLANE, "tank", TANK, "vehicle", VEHICLE); + + private ModItems() { + } + + private static Registered kind(String kind) { + return Registration.register(Registries.ITEM, kind, key -> new AircraftItem(kind, new Item.Properties().setId(key).stacksTo(1))); + } + + /** + * Declares the entries, with a 1.0.0 item for each original vehicle in {@code content}; the + * loader module registers them. + */ + public static void init(ContentRegistry content) { + for (AircraftDefinition def : content.all()) { + String name = def.name(); + // Only the imported content existed in 1.0.0; pack vehicles never had their own item. + if (!def.pack().equals(ContentSource.ORIGINAL) || BY_KIND.containsKey(name)) { + // 1.0.0 could not have had such an item either: it would have clashed the same way. + continue; + } + String kind = def.kind(); + Registration.register(Registries.ITEM, name, key -> new LegacyAircraftItem(kind, name, + new Item.Properties().setId(key).stacksTo(1))); + } + } + + /** The generic item of a vehicle kind ({@code helicopter}, {@code plane}, {@code tank} or {@code vehicle}). */ + public static Item forKind(String kind) { + Registered item = BY_KIND.get(kind); + if (item == null) { + throw new IllegalArgumentException("Unknown vehicle kind " + kind); + } + return item.get(); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 0000000..ce9a1da --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; + +public final class ModParticles { + /** Ignores the particle limit: an explosion that loses its flash to the cap looks broken. */ + public static final Registered> GLOW = Registration.register(Registries.PARTICLE_TYPE, "glow", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return GlowParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return GlowParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java similarity index 86% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java index 16da1c7..ebe0a0a 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java @@ -5,15 +5,18 @@ import com.jnhprojects.mcheli.entity.HelicopterEntity; import com.jnhprojects.mcheli.entity.PlaneEntity; import com.jnhprojects.mcheli.entity.TankEntity; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.tags.BlockTags; +import net.minecraft.tags.TagKey; +import net.minecraft.world.level.block.Block; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; -import net.minecraftforge.common.Tags; import org.jspecify.annotations.Nullable; /** Damage rules shared by bullets and explosions. */ @@ -52,12 +55,15 @@ public static boolean hurt(Entity target, DamageSource source, float damage, @Nu return target.hurtServer(level, source, damage * factor(def, target)); } + /** The convention tag every loader fills, so modded panes count too. */ + private static final TagKey GLASS_PANES = TagKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("c", "glass_panes")); + /** * {@code BulletBreakableBlocks} with its default list: panes, grass, flowers, vines, wheat, * sugar cane and lily pads, which a round shoots through. */ public static boolean isBulletBreakable(BlockState state) { - return state.is(Tags.Blocks.GLASS_PANES) + return state.is(GLASS_PANES) || state.is(BlockTags.FLOWERS) || state.is(Blocks.SHORT_GRASS) || state.is(Blocks.TALL_GRASS) || state.is(Blocks.FERN) || state.is(Blocks.LARGE_FERN) diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java new file mode 100644 index 0000000..4f4b0dc --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java @@ -0,0 +1,232 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.HashSet; +import java.util.Set; + +/** + * The look of an MCHeli explosion, ported from {@code MCH_Explosion.effectExplosion} and + * {@code effectExplosionInWater}: a fireball, ground dust thrown up from the crater, and columns + * and rings of smoke that hang around for seconds. It runs on each client that receives the + * explosion; the burning debris arcs are real flares spawned by the server. + */ +public final class ExplosionEffect { + /** The largest crash fireball drawn; beyond this the particle count gets out of hand. */ + public static final float MAX_CRASH_SIZE = 12.0F; + /** The widest explosion flash, in blocks; past this it washes out the whole screen. */ + private static final float MAX_FLASH_RADIUS = 30.0F; + + private ExplosionEffect() { + } + + /** + * Not in the original: a crashing vehicle goes up in a far bigger show than its fuel explosion + * alone makes. The same shell of smoke as a missile hit in mid-air, a cluster of fireballs + * around it, a plume of smoke that keeps climbing for seconds and burning fuel thrown about. + * The fire glows for several seconds afterwards, which at night lights up the smoke above it. + */ + public static void crash(Level level, double x, double y, double z, float size) { + play(level, x, y, z, size, false); + RandomSource random = level.getRandom(); + level.addAlwaysVisibleParticle(GlowParticleOptions.flash(GlowParticleOptions.FLASH, Math.min(size * 3.0F, MAX_FLASH_RADIUS), 20), + true, x, y + size * 0.3, z, 0.0, 0.0, 0.0); + int fireballs = Math.max(3, (int) (size / 2.0F)); + for (int i = 0; i < fireballs; i++) { + double r = size * 0.5 * random.nextDouble(); + double a = random.nextDouble() * Math.PI * 2.0; + double fx = x + Math.cos(a) * r; + double fy = y + random.nextDouble() * size * 0.4; + double fz = z + Math.sin(a) * r; + level.addAlwaysVisibleParticle(ParticleTypes.EXPLOSION_EMITTER, true, fx, fy, fz, 1.0, 0.0, 0.0); + level.addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.FIRE, size * (0.4F + random.nextFloat() * 0.3F), + 80 + random.nextInt(60)), true, fx, fy, fz, 0.0, 0.01, 0.0); + } + for (int i = 0; i < size * 4; i++) { + level.addAlwaysVisibleParticle(ParticleTypes.CAMPFIRE_SIGNAL_SMOKE, true, + x + (random.nextDouble() - 0.5) * size * 0.6, y + random.nextDouble() * 2.0, z + (random.nextDouble() - 0.5) * size * 0.6, + (random.nextDouble() - 0.5) * 0.02, 0.05 + random.nextDouble() * 0.1, (random.nextDouble() - 0.5) * 0.02); + } + for (int i = 0; i < size * 10; i++) { + double a = random.nextDouble() * Math.PI * 2.0; + double speed = (0.2 + random.nextDouble() * 0.4) * size / 6.0; + double up = random.nextDouble() * speed; + level.addAlwaysVisibleParticle(random.nextInt(4) == 0 ? ParticleTypes.LAVA : ParticleTypes.FLAME, true, x, y + 0.5, z, + Math.cos(a) * speed, up, Math.sin(a) * speed); + if (i % 4 == 0) { + level.addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.EMBER, 0.5F + random.nextFloat() * 0.4F, + 20 + random.nextInt(20)), true, x, y + 0.5, z, Math.cos(a) * speed, up, Math.sin(a) * speed); + } + } + } + + /** + * Not in the original: the light of the blast itself, a white hot flash inside a wider red + * glow, and a few embers thrown out. Faint by day; at night it lights up the smoke. + */ + private static void flash(Level level, double x, double y, double z, float size) { + RandomSource random = level.getRandom(); + float radius = Math.min(2.0F + size * 1.5F, MAX_FLASH_RADIUS); + level.addAlwaysVisibleParticle(GlowParticleOptions.flash(GlowParticleOptions.FLASH, radius, 8 + (int) size), true, x, y, z, 0.0, 0.0, 0.0); + level.addAlwaysVisibleParticle(GlowParticleOptions.flash(GlowParticleOptions.EMBER, Math.min(radius * 1.6F, MAX_FLASH_RADIUS), + 14 + (int) (size * 2.0F)), true, x, y, z, 0.0, 0.0, 0.0); + int embers = Math.min((int) (size * 2.0F), 24); + for (int i = 0; i < embers; i++) { + double a = random.nextDouble() * Math.PI * 2.0; + double speed = 0.2 + random.nextDouble() * 0.3 * Math.min(size, 4.0F); + level.addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.EMBER, 0.3F + random.nextFloat() * 0.3F, + 15 + random.nextInt(15)), true, x, y, z, Math.cos(a) * speed, 0.1 + random.nextDouble() * speed, Math.sin(a) * speed); + } + } + + public static void play(Level level, double x, double y, double z, float size, boolean inWater) { + if (size <= 0.0F) { + return; + } + if (inWater) { + splash(level, x, y, z, size); + return; + } + RandomSource random = level.getRandom(); + level.addAlwaysVisibleParticle(size >= 2.0F ? ParticleTypes.EXPLOSION_EMITTER : ParticleTypes.EXPLOSION, true, x, y, z, 1.0, 0.0, 0.0); + flash(level, x, y, z, size); + + int count = 0; + int puffEvery = (int) Math.max(size, 4.0F); + for (BlockPos pos : affectedBlocks(level, x, y, z, size)) { + count++; + double bx = pos.getX() + random.nextFloat(); + double by = pos.getY() + random.nextFloat(); + double bz = pos.getZ() + random.nextFloat(); + double mx = bx - x; + double my = by - y; + double mz = bz - z; + double d = Math.sqrt(mx * mx + my * my + mz * mz); + if (d == 0.0) { + continue; + } + double push = 0.5 / (d / size + 0.1) * (random.nextFloat() * random.nextFloat() + 0.3F) * 0.5; + mx = mx / d * push; + my = my / d * push; + mz = mz / d * push; + double px = (bx + x) / 2.0; + double py = (by + y) / 2.0; + double pz = (bz + z) / 2.0; + double angle = Math.toRadians(random.nextInt(360)); + + if (count % 4 == 0) { + // Dirt and stone thrown up from the ground under the blast. + BlockState ground = level.getBlockState(BlockPos.containing(px + 0.5, py - 0.5, pz + 0.5)); + if (!ground.isAir()) { + float force = Math.min(size / 3.0F, 2.0F) * (0.5F + random.nextFloat() * 0.5F); + BlockParticleOption dust = new BlockParticleOption(ParticleTypes.BLOCK, ground); + for (int i = 0; i < 3; i++) { + level.addAlwaysVisibleParticle(dust, true, px, py + 1.0, pz, + Math.sin(angle) * force * 0.5, 0.5 + my / 5.0 * force, Math.cos(angle) * force * 0.5); + } + } + } + if (size <= 1.0F || count % puffEvery == 0) { + // Smoke either shoots up as a column or rolls out along the ground. + if (random.nextBoolean()) { + my *= 3.0; + mx *= 0.1; + mz *= 0.1; + } else { + my *= 0.2; + mx *= 3.0; + mz *= 3.0; + } + level.addAlwaysVisibleParticle(ParticleTypes.CAMPFIRE_COSY_SMOKE, true, px, py, pz, mx * 0.3, my * 0.3, mz * 0.3); + for (int i = 0; i < 2; i++) { + level.addAlwaysVisibleParticle(ParticleTypes.LARGE_SMOKE, true, px, py, pz, mx, my, mz); + } + if (random.nextInt(3) == 0) { + level.addAlwaysVisibleParticle(ParticleTypes.FLAME, true, px, py, pz, mx * 0.5, my * 0.5, mz * 0.5); + } + } + } + } + + /** + * The blocks the original's effect ray marched through: sixteen by sixteen rays from the + * centre, each running until block resistance used up its strength. + */ + @SuppressWarnings("deprecation") // The plain block resistance is what the original used here. + private static Set affectedBlocks(Level level, double x, double y, double z, float size) { + Set out = new HashSet<>(); + RandomSource random = level.getRandom(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int i = 0; i < 16; i++) { + for (int j = 0; j < 16; j++) { + for (int k = 0; k < 16; k++) { + if (i != 0 && i != 15 && j != 0 && j != 15 && k != 0 && k != 15) { + continue; + } + double dx = i / 15.0F * 2.0F - 1.0F; + double dy = j / 15.0F * 2.0F - 1.0F; + double dz = k / 15.0F * 2.0F - 1.0F; + double len = Math.sqrt(dx * dx + dy * dy + dz * dz); + dx /= len; + dy /= len; + dz /= len; + float strength = size * (0.7F + random.nextFloat() * 0.6F); + double cx = x; + double cy = y; + double cz = z; + for (float step = 0.3F; strength > 0.0F; strength -= step * 0.75F) { + cursor.set(Mth.floor(cx), Mth.floor(cy), Mth.floor(cz)); + BlockState state = level.getBlockState(cursor); + if (!state.isAir()) { + strength -= (state.getBlock().getExplosionResistance() + 0.3F) * step; + } + if (strength > 0.0F) { + out.add(cursor.immutable()); + } + cx += dx * step; + cy += dy * step; + cz += dz * step; + } + } + } + } + return out; + } + + /** {@code effectExplosionInWater}: a dome of spray from the water around the charge. */ + private static void splash(Level level, double x, double y, double z, float size) { + RandomSource random = level.getRandom(); + int range = Math.max((int) (size + 0.5), 1); + int ex = (int) (x + 0.5); + int ey = (int) (y + 0.5); + int ez = (int) (z + 0.5); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int dy = -range; dy <= range; dy++) { + for (int dx = -range; dx <= range; dx++) { + for (int dz = -range; dz <= range; dz++) { + if (dx * dx + dy * dy + dz * dz >= range * range) { + continue; + } + cursor.set(ex + dx, ey + dy, ez + dz); + if (!level.getFluidState(cursor).is(FluidTags.WATER)) { + continue; + } + for (int n = random.nextInt(2); n > 0; n--) { + double up = 1.0 - Math.sqrt(dx * dx + dz * dz) / range + random.nextFloat() * 0.4 * range * 0.4; + level.addAlwaysVisibleParticle(ParticleTypes.SPLASH, true, cursor.getX(), cursor.getY() + 1.0, cursor.getZ(), + (double) dx / range * (random.nextFloat() - 0.2), up, (double) dz / range * (random.nextFloat() - 0.2)); + level.addAlwaysVisibleParticle(ParticleTypes.BUBBLE, true, cursor.getX(), cursor.getY(), cursor.getZ(), 0.0, up * 0.5, 0.0); + } + } + } + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java similarity index 98% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java index 41a7bbd..f7fe3e8 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -183,6 +183,8 @@ private static final class Calculator extends ExplosionDamageCalculator { this.level = level; } + // Vanilla's own calculator reads the block's resistance the same deprecated way. + @SuppressWarnings("deprecation") @Override public Optional getBlockExplosionResistance(Explosion explosion, BlockGetter getter, BlockPos pos, BlockState block, FluidState fluid) { @@ -192,7 +194,7 @@ public Optional getBlockExplosionResistance(Explosion explosion, BlockGet if (block.isAir()) { return Optional.empty(); } - float resistance = block.getExplosionResistance(getter, pos, explosion); + float resistance = block.getBlock().getExplosionResistance(); return Optional.of(resistance * (level.getRandom().nextFloat() * 0.2F + 0.2F)); } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java new file mode 100644 index 0000000..b8142e9 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java @@ -0,0 +1,220 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.function.Predicate; + +/** + * Lock-on for seeking missiles, ported from {@code MCH_WeaponGuidanceSystem}. It runs on the + * client of the crew member aiming; the locked entity travels to the server with the fire request. + * + *

A target is acquired inside a cone around the aim, then the lock count climbs while it stays + * in the cone. Stealth shrinks both the range and the cone and stretches the time to lock. + */ +public final class Guidance { + public final boolean canLockInAir; + public final boolean canLockOnGround; + public final boolean canLockInWater = false; + public final boolean ridableOnly; + public final double lockRange = 200.0; + public final int lockAngle = 5; + private final int lockCountMax; + private final @Nullable Predicate filter; + + private @Nullable Entity target; + private @Nullable Entity lastLocked; + private int lockCount; + private int prevLockCount; + private int continueLockCount; + private int soundCount; + + public Guidance(boolean air, boolean ground, boolean ridableOnly, int lockTime, @Nullable Predicate filter) { + this.canLockInAir = air; + this.canLockOnGround = ground; + this.ridableOnly = ridableOnly; + this.lockCountMax = Math.max(lockTime, 1); + this.filter = filter; + } + + public int lockCount() { + return lockCount; + } + + public int lockCountMax() { + float stealth = stealth(target); + return (int) (lockCountMax + lockCountMax * stealth); + } + + public @Nullable Entity target() { + return lockCount > 0 && target != null && !target.isRemoved() ? target : null; + } + + public @Nullable Entity lastLocked() { + return lastLocked; + } + + public boolean isLocked() { + return lockCount == lockCountMax() && lastLocked != null; + } + + /** Called every client tick: a lock decays unless {@link #lock} kept it alive this tick. */ + public void tick() { + if (lockCount != prevLockCount) { + prevLockCount = lockCount; + } else { + lockCount = 0; + prevLockCount = 0; + } + } + + /** + * Advances the lock for one tick of aiming from {@code user}'s view. + * + * @param eye where the view is seen from. The original used the player's position, which on a + * 1.7.10 client was eye height; gunner sights and launcher cameras sit higher still, + * and a 5 degree cone measured from the wrong point misses targets close by. + * @return true once the lock is complete; {@link #lastLocked()} is the target to send + */ + public boolean lock(Entity user, Vec3 eye) { + if (lockCount == 0) { + acquire(user, eye); + return false; + } + if (target == null || target.isRemoved() || !inLockableState(target)) { + clear(); + return false; + } + double range = lockRange * (1.0F - stealth(target)); + if (user.distanceToSqr(target) >= range * range) { + clear(); + return false; + } + soundCount = (soundCount + 1) % 15; + if (inCone(eye, user.getYRot(), user.getXRot(), target, lockAngle)) { + if (lockCount < lockCountMax()) { + lockCount++; + } + } else if (continueLockCount > 0) { + continueLockCount--; + if (continueLockCount <= 0 && lockCount > 0) { + lockCount--; + } + } else { + continueLockCount = 0; + lockCount--; + } + if (lockCount >= lockCountMax()) { + if (continueLockCount <= 0) { + continueLockCount = Math.min(lockCountMax() / 3, 20); + } + lastLocked = target; + prevLockCount = lockCount - 1; + return true; + } + return false; + } + + /** True on the tick the target should hear that it is being tracked. */ + public boolean shouldNotifyTarget() { + return lockCount > 0 && soundCount == 1; + } + + private void acquire(Entity user, Vec3 eye) { + Entity best = null; + double bestDistance = lockRange * lockRange * 2.0; + for (Entity entity : user.level().getEntities(user, user.getBoundingBox().inflate(lockRange), this::canLock)) { + double d = entity.distanceToSqr(user); + float stealth = 1.0F - stealth(entity); + double range = lockRange * stealth; + float angle = lockAngle * (stealth / 2.0F + 0.5F); + if (d < range * range && d < bestDistance && inCone(eye, user.getYRot(), user.getXRot(), entity, angle)) { + Vec3 to = new Vec3(entity.getX(), entity.getY() + entity.getBbHeight() / 2.0F, entity.getZ()); + HitResult hit = user.level().clip(new ClipContext(eye, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, user)); + if (hit.getType() == HitResult.Type.MISS) { + best = entity; + bestDistance = d; + } + } + } + target = best; + if (best != null) { + lockCount++; + } + } + + public void clear() { + target = null; + lockCount = 0; + continueLockCount = 0; + soundCount = 0; + } + + private boolean canLock(Entity entity) { + if (ridableOnly && entity instanceof Player && entity.getVehicle() == null) { + return false; + } + if (!(entity instanceof LivingEntity) && !(entity instanceof AircraftEntity)) { + return false; + } + if (filter != null && !filter.test(entity)) { + return false; + } + return inLockableState(entity); + } + + private boolean inLockableState(Entity entity) { + if (!canLockInWater && entity.isInWater()) { + return false; + } + boolean onGround = isOnGround(entity); + return onGround ? canLockOnGround : canLockInAir; + } + + /** On the ground, or less than twelve blocks above something solid. */ + public static boolean isOnGround(Entity entity) { + if (entity.onGround()) { + return true; + } + BlockPos.MutableBlockPos pos = BlockPos.containing(entity.getX() + 0.5, entity.getY() + 0.5, entity.getZ() + 0.5).mutable(); + for (int i = 0; i < 12; i++) { + if (!entity.level().getBlockState(pos).isAir() && !entity.level().getFluidState(pos).is(FluidTags.WATER)) { + return true; + } + pos.move(0, -1, 0); + } + return false; + } + + public static float stealth(@Nullable Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + return vehicle.stealth(); + } + return entity != null && entity.getVehicle() instanceof AircraftEntity vehicle ? vehicle.stealth() : 0.0F; + } + + /** {@code inLockRange}: whether {@code target} is within {@code angle} degrees of the aim. */ + public static boolean inCone(Entity from, float yaw, float pitch, Entity target, float angle) { + return inCone(from.position(), yaw, pitch, target, angle); + } + + public static boolean inCone(Vec3 from, float yaw, float pitch, Entity target, float angle) { + double dx = target.getX() - from.x; + double dy = target.getY() + target.getBbHeight() / 2.0F - from.y; + double dz = target.getZ() - from.z; + float targetYaw = (float) Math.toDegrees(Math.atan2(dz, dx)); + float diffYaw = Mth.positiveModulo(targetYaw - yaw - 90.0F, 360.0F); + float targetPitch = -(float) Math.toDegrees(Math.atan2(dy, Math.sqrt(dx * dx + dz * dz))); + float diffPitch = targetPitch - pitch; + return (diffYaw < angle || diffYaw > 360.0F - angle) && Math.abs(diffPitch) < angle; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java new file mode 100644 index 0000000..44df1ac --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -0,0 +1,77 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Resupplying a weapon from the crew member's inventory, the {@code supplyAmmo} path of the + * original. There the vehicle's inventory screen did it; until that screen exists a key does. + * One supply costs the weapon's {@code Item} lines and adds {@code SuppliedNum} rounds. + */ +public final class Resupply { + private Resupply() { + } + + public static boolean fromInventory(ServerPlayer player, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + int seat = vehicle.seatOf(player); + WeaponSet set = weapons != null ? weapons.current(seat) : null; + if (set == null || vehicle.isDestroyed()) { + return false; + } + WeaponDefinition w = set.definition(); + if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { + return false; + } + if (!player.getAbilities().instabuild) { + if (!canPay(player.getInventory(), w)) { + return false; + } + pay(player.getInventory(), w); + } + set.supply(); + if (set.ammo() <= 0) { + set.reloadMagazine(); + } + return true; + } + + private static boolean canPay(Inventory inventory, WeaponDefinition w) { + for (RoundItem cost : w.roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null || inventory.countItem(item) < cost.count()) { + return false; + } + } + return true; + } + + private static void pay(Inventory inventory, WeaponDefinition w) { + for (RoundItem cost : w.roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + int left = cost.count(); + for (int i = 0; i < inventory.getContainerSize() && left > 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.is(item)) { + continue; + } + if (item == Items.WATER_BUCKET || item == Items.LAVA_BUCKET) { + // Buckets give their bucket back, one at a time. + inventory.setItem(i, new ItemStack(Items.BUCKET)); + left--; + } else { + int take = Math.min(left, stack.getCount()); + stack.shrink(take); + left -= take; + } + } + } + inventory.setChanged(); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java new file mode 100644 index 0000000..6a9f53c --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java @@ -0,0 +1,40 @@ +package com.jnhprojects.mcheli.weapon; + +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +/** + * One trigger pull, the port of {@code MCH_WeaponParam}. The weapon set fills in the aim and the + * muzzle position; the client fills in the two option values (a locked target, a fire mode) and + * the server receives them with the fire request. + */ +public final class Shot { + /** The vehicle firing, or the player for hand held weapons. */ + public final Entity shooter; + /** Who pulled the trigger. Damage and kills are credited to this entity. */ + public final @Nullable Entity user; + public double x; + public double y; + public double z; + public float yaw; + public float pitch; + public float roll; + public int option1; + public int option2; + public boolean infiniteAmmo; + /** Set by the weapon set when the magazine ran dry with this shot. */ + public boolean reload; + + public Shot(Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + this.x = shooter.getX(); + this.y = shooter.getY(); + this.z = shooter.getZ(); + } + + /** The entity whose view steers guided weapons: the user if there is one, else the shooter. */ + public Entity aimer() { + return user != null ? user : shooter; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java similarity index 100% rename from forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java rename to minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java 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 new file mode 100644 index 0000000..b358a4c --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -0,0 +1,446 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * One selectable weapon of a vehicle: every mount that fires the same weapon, with the magazine, + * reload, heat and recoil they share. A port of {@code MCH_WeaponSet} together with the parts of + * {@code MCH_WeaponBase} and {@code MCH_WeaponCreator} that are not specific to a weapon type. + * + *

Both sides keep one. The server's is authoritative for ammunition and decides what is fired; + * the client's runs with the slightly longer intervals the original used there, so the client never + * asks to fire faster than the server accepts. + */ +public final class WeaponSet { + private static final int SWITCH_WAIT = 15; + + /** Fires one mount. Returns whether the shot happened, which consumes a round. */ + @FunctionalInterface + public interface Trigger { + boolean fire(WeaponSet set, int mount, Shot shot); + } + + private final RandomSource random = RandomSource.create(); + private final WeaponDefinition def; + private final List mounts; + private final boolean clientSide; + + public final int interval; + public final int heatCount; + private final int numMode; + + private int currentMount; + private int mode; + private int ammo; + private int restAmmo; + public int currentHeat; + private int cooldownSpeed = 1; + public int countWait; + public int countReloadWait; + private final int[] lastUsedCount; + /** Ticks since each mount last fired, {@code MCH_WeaponBase.tick}. */ + final int[] ticks; + public int soundWait; + /** Whether the shot being fired may play its sound, see {@code SoundDelay}. */ + public boolean soundAllowed = true; + + /** Aim relative to the hull, following the crew member who operates the weapon. */ + public float rotationYaw; + public float rotationPitch; + public float prevRotationYaw; + public float prevRotationPitch; + public final float defaultRotationYaw; + /** How far behind the pilot's view a turret weapon lags, for drawing. */ + public float rotationTurretYaw; + + public float barrelSpeed; + public float barrel; + public float prevBarrel; + private final Recoil[] recoil; + + // Close air support run, server only. See ServerFiring. + @Nullable Entity casUser; + @Nullable Entity casShooter; + double casX; + double casY; + double casZ; + int casDirection; + int casStartTick; + int casCount = 3; + + public WeaponSet(WeaponDefinition def, List mounts, boolean clientSide) { + this.def = def; + this.mounts = List.copyOf(mounts); + this.clientSide = clientSide; + this.lastUsedCount = new int[mounts.size()]; + this.ticks = new int[mounts.size()]; + this.recoil = new Recoil[mounts.size()]; + for (int i = 0; i < recoil.length; i++) { + recoil[i] = new Recoil(def.recoilBufCount, def.recoilBufCountSpeed); + } + this.defaultRotationYaw = mounts.isEmpty() ? 0.0F : mounts.getFirst().defaultYaw(); + this.rotationYaw = defaultRotationYaw; + this.prevRotationYaw = defaultRotationYaw; + + int wait = def.delay; + if (def.type == WeaponType.CAS && wait > -250) { + wait = -250; + } + int heat = def.heatCount; + if (def.maxHeatCount > 0 && heat < 2) { + heat = 2; + } + if (clientSide) { + wait = clientInterval(wait); + heat++; + } + this.interval = wait; + this.heatCount = heat; + this.numMode = def.type == WeaponType.MACHINE_GUN_2 && def.explosion == 0 ? 0 : def.modeNum; + } + + /** The original gave the client longer intervals so its requests never outran the server. */ + private static int clientInterval(int wait) { + if (wait < 4) { + return wait + 1; + } else if (wait < 7) { + return wait + 2; + } else if (wait < 10) { + return wait + 3; + } else if (wait < 20) { + return wait + 6; + } + wait += 10; + return wait >= 40 ? -wait : wait; + } + + public WeaponDefinition definition() { + return def; + } + + public String name() { + return def.name(); + } + + public List mounts() { + return mounts; + } + + public WeaponMount firstMount() { + return mounts.getFirst(); + } + + public int currentMount() { + return currentMount; + } + + // Ammunition + + public int ammo() { + return ammo; + } + + public int magazineSize() { + return def.round; + } + + public int restAmmo() { + return restAmmo; + } + + public int maxAmmo() { + return def.maxAmmo; + } + + public void setAmmo(int n) { + ammo = n; + } + + /** Everything not in the magazine, capped so magazine plus reserve never exceed {@code MaxAmmo}. */ + public void setRestAmmo(int n) { + restAmmo = Math.min(n, def.maxAmmo - ammo); + } + + /** {@code supplyRestAllAmmo}: one resupply adds {@code SuppliedNum} rounds. */ + public boolean supply() { + if (restAmmo + ammo < def.maxAmmo) { + setRestAmmo(restAmmo + ammo + def.suppliedNum); + return true; + } + return false; + } + + public boolean isInPreparation() { + return countWait < 0 || countReloadWait > 0; + } + + public boolean isReloading() { + return countReloadWait > 0; + } + + public boolean canUse() { + return countWait == 0; + } + + public boolean isLongDelay() { + return def.delay > 4; + } + + public boolean isOverheated() { + return def.maxHeatCount > 0 && currentHeat >= def.maxHeatCount; + } + + public void reload() { + if (magazineSize() > 0 && ammo < magazineSize() && def.reloadTime > 0) { + countReloadWait = def.reloadTime; + if (clientSide) { + ammo = 0; + } else { + countReloadWait = Math.max(countReloadWait - 20, 1); + } + } + } + + public void reloadMagazine() { + int needed = magazineSize() - ammo; + if (needed > 0) { + needed = Math.min(needed, restAmmo); + ammo += needed; + setRestAmmo(restAmmo - needed); + } + } + + // Modes + + public int mode() { + return def.fixMode > 0 ? def.fixMode : mode; + } + + public boolean switchMode() { + if (def.fixMode > 0) { + return false; + } + int before = mode(); + mode = numMode > 0 ? (mode() + 1) % numMode : 0; + if (before == mode()) { + return false; + } + waitAfterSwitch(SWITCH_WAIT); + return true; + } + + /** {@code getName}: the display name with the mode suffix the weapon class added. */ + public String displayName() { + String suffix = switch (def.type) { + case MACHINE_GUN_2 -> mode() == 0 ? "" : " [HE]"; + case ROCKET -> mode() == 0 ? "" : " [HEIAP]"; + case AT_MISSILE -> mode() == 1 ? " [TA]" : ""; + case TV_MISSILE -> switch (mode()) { + case 0 -> " [TV]"; + case 2 -> " [TA]"; + default -> ""; + }; + case null, default -> ""; + }; + return def.displayName + suffix; + } + + public void onSwitchWeapon(boolean infiniteAmmo) { + waitAfterSwitch(clientSide ? SWITCH_WAIT + 10 : SWITCH_WAIT); + currentMount = 0; + if (infiniteAmmo) { + ammo = magazineSize(); + } + } + + private void waitAfterSwitch(int wait) { + if (countWait >= -wait) { + countWait = countWait > wait ? -countWait : -wait; + } + } + + /** Another weapon of the same {@code Group} fired; this one waits and reloads with it. */ + public void waitForGroup(boolean reload) { + countWait = interval; + countReloadWait = 0; + if (reload && magazineSize() > 0 && def.reloadTime > 0) { + countReloadWait = clientSide ? def.reloadTime : Math.max(def.reloadTime - 20, 1); + } + } + + // Firing + + /** + * Fires the next mount if the set is ready. The aim is the shooter's rotation plus this set's + * turret aim and the mount's fixed offset, scattered by {@code Accuracy}. + */ + public boolean use(Shot shot, Trigger trigger) { + if (magazineSize() > 0 && ammo <= 0 || isOverheated()) { + return false; + } + WeaponMount mount = mounts.get(currentMount); + soundAllowed = soundWait == 0; + float yaw = shot.shooter.getYRot() + rotationYaw + mount.yaw(); + float pitch = shot.shooter.getXRot() + rotationPitch + mount.pitch(); + if (def.accuracy > 0.0F) { + yaw += (random.nextFloat() - 0.5F) * def.accuracy; + pitch += (random.nextFloat() - 0.5F) * def.accuracy; + } + shot.yaw = Mth.wrapDegrees(yaw); + shot.pitch = Mth.wrapDegrees(pitch); + if (!trigger.fire(this, currentMount, shot)) { + return false; + } + ticks[currentMount] = 0; + + if (def.maxHeatCount > 0) { + cooldownSpeed = 1; + currentHeat += heatCount; + if (currentHeat >= def.maxHeatCount) { + currentHeat += 30; + } + } + if (def.soundDelay > 0 && soundWait == 0) { + soundWait = def.soundDelay; + } + lastUsedCount[currentMount] = Math.abs(interval); + if (isCooldownReloadTime() && def.reloadTime - 10 > lastUsedCount[currentMount]) { + lastUsedCount[currentMount] = def.reloadTime - 10; + } + currentMount = (currentMount + 1) % mounts.size(); + countWait = interval; + countReloadWait = 0; + if (ammo > 0) { + ammo--; + } + if (ammo <= 0) { + if (shot.infiniteAmmo && restAmmo < magazineSize()) { + setRestAmmo(magazineSize()); + } + reload(); + shot.reload = true; + } + return true; + } + + /** Missiles keep their mount empty for the whole reload, which hides the missile part meanwhile. */ + private boolean isCooldownReloadTime() { + return def.type == WeaponType.AA_MISSILE || def.type == WeaponType.AS_MISSILE || def.type == WeaponType.AT_MISSILE; + } + + /** Whether {@code mount} fired recently enough to show muzzle effects or keep a missile hidden. */ + public boolean isUsed(int mount) { + if (mount >= lastUsedCount.length) { + return false; + } + int count = lastUsedCount[mount]; + return interval >= 4 && count > interval / 2 || count >= 4; + } + + public int ticksSinceShot(int mount) { + return ticks[mount]; + } + + // Ticking + + /** {@code MCH_WeaponSet.update}, once per tick on both sides. */ + public void tick(boolean used) { + if (countReloadWait > 0) { + countReloadWait--; + if (countReloadWait == 0) { + reloadMagazine(); + } + } + for (int i = 0; i < lastUsedCount.length; i++) { + if (lastUsedCount[i] > 0) { + if (lastUsedCount[i] != 4 || currentMount == 0 && canUse() && (ammo > 0 || def.maxAmmo <= 0)) { + lastUsedCount[i]--; + } + } + } + if (currentHeat > 0) { + if (currentHeat < def.maxHeatCount) { + cooldownSpeed++; + } + currentHeat = Math.max(currentHeat - (cooldownSpeed / 20 + 1), 0); + } + if (countWait > 0) { + countWait--; + } else if (countWait < 0) { + countWait++; + } + prevRotationYaw = rotationYaw; + prevRotationPitch = rotationPitch; + if (countWait != 0) { + for (int i = 0; i < ticks.length; i++) { + ticks[i]++; + } + } + if (soundWait > 0) { + soundWait--; + } + + if (used && barrelSpeed < 75.0F) { + barrelSpeed = Math.min(barrelSpeed + 25 + random.nextInt(3), 74.0F); + } + prevBarrel = barrel; + barrel += barrelSpeed; + if (barrel >= 360.0F) { + barrel -= 360.0F; + prevBarrel -= 360.0F; + } + if (barrelSpeed > 0.0F) { + barrelSpeed = Math.max(barrelSpeed - 1.48F, 0.0F); + } + } + + /** {@code updateWeapon}: the recoil kick of one mount. */ + public void tickRecoil(int mount, boolean used) { + Recoil r = recoil[mount]; + r.prev = r.value; + if (used && r.count <= 0) { + r.count = r.max; + } + if (r.count > 0) { + if (r.max <= 1) { + r.value = 1.0F; + } else if (r.max == 2) { + r.value = r.count == 2 ? 1.0F : 0.6F; + } else { + if (r.count > r.max / 2) { + r.count -= r.speed; + } + r.value = Mth.sin((float) r.count / r.max * Mth.PI); + } + r.count--; + } else { + r.value = 0.0F; + } + } + + public float recoil(int mount, float partialTicks) { + Recoil r = recoil[Math.min(mount, recoil.length - 1)]; + return Mth.lerp(partialTicks, r.prev, r.value); + } + + private static final class Recoil { + final int max; + final int speed; + int count; + float value; + float prev; + + Recoil(int max, int speed) { + this.max = max; + this.speed = speed; + } + } +} diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/helicopter.json b/minecraft-26.3/src/main/resources/assets/mcheli/items/helicopter.json similarity index 100% rename from forge-26.3/src/main/resources/assets/mcheli/items/helicopter.json rename to minecraft-26.3/src/main/resources/assets/mcheli/items/helicopter.json diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/plane.json b/minecraft-26.3/src/main/resources/assets/mcheli/items/plane.json similarity index 100% rename from forge-26.3/src/main/resources/assets/mcheli/items/plane.json rename to minecraft-26.3/src/main/resources/assets/mcheli/items/plane.json diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/tank.json b/minecraft-26.3/src/main/resources/assets/mcheli/items/tank.json similarity index 100% rename from forge-26.3/src/main/resources/assets/mcheli/items/tank.json rename to minecraft-26.3/src/main/resources/assets/mcheli/items/tank.json diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/vehicle.json b/minecraft-26.3/src/main/resources/assets/mcheli/items/vehicle.json similarity index 100% rename from forge-26.3/src/main/resources/assets/mcheli/items/vehicle.json rename to minecraft-26.3/src/main/resources/assets/mcheli/items/vehicle.json diff --git a/minecraft-26.3/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-26.3/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 0000000..296e539 --- /dev/null +++ b/minecraft-26.3/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/minecraft-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 0000000..339b972 Binary files /dev/null and b/minecraft-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png differ diff --git a/minecraft-26.3/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-26.3/src/main/resources/data/mcheli/damage_type/bullet.json new file mode 100644 index 0000000..92ee676 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/damage_type/bullet.json @@ -0,0 +1,5 @@ +{ + "exhaustion": 0.1, + "message_id": "mcheli.bullet", + "scaling": "when_caused_by_living_non_player" +} diff --git a/minecraft-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json new file mode 100644 index 0000000..1042db9 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "mcheli:bullet" + ] +} diff --git a/minecraft-26.3/src/main/resources/mcheli_logo.png b/minecraft-26.3/src/main/resources/mcheli_logo.png new file mode 100644 index 0000000..95c7219 Binary files /dev/null and b/minecraft-26.3/src/main/resources/mcheli_logo.png differ diff --git a/forge-26.3/src/main/resources/pack.mcmeta b/minecraft-26.3/src/main/resources/pack.mcmeta similarity index 100% rename from forge-26.3/src/main/resources/pack.mcmeta rename to minecraft-26.3/src/main/resources/pack.mcmeta diff --git a/neoforge-1.21.1/build.gradle b/neoforge-1.21.1/build.gradle new file mode 100644 index 0000000..60829c3 --- /dev/null +++ b/neoforge-1.21.1/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java' + id 'idea' + id 'eclipse' + id 'net.neoforged.moddev' +} + +apply from: rootProject.file('gradle/loader.gradle') + +// The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this +// one (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +neoForge { + version = neoforge_version + + runs { + configureEach { + gameDirectory = rootProject.file("run-${minecraft_version}") + systemProperty 'mcheli.legacyZip', legacyZip + } + client { + client() + } + server { + server() + programArgument '--nogui' + } + } + + mods { + mcheli { + sourceSet sourceSets.main + } + } +} + +repositories { + mavenCentral() +} diff --git a/neoforge-1.21.1/gradle.properties b/neoforge-1.21.1/gradle.properties new file mode 100644 index 0000000..f9272dd --- /dev/null +++ b/neoforge-1.21.1/gradle.properties @@ -0,0 +1,7 @@ +# Minecraft 1.21.1 on NeoForge +loader=neoforge +minecraft_version=1.21.1 +minecraft_version_range=[1.21.1,1.21.2) +neoforge_version=21.1.265 +loader_version_range=[21.1.200,) +java_version=21 diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java new file mode 100644 index 0000000..734b0ed --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -0,0 +1,91 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.CommonHooks; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.Registry; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.AddPackFindersEvent; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.server.ServerAboutToStartEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import net.neoforged.neoforge.registries.NeoForgeRegistries; +import net.neoforged.neoforge.registries.RegisterEvent; + +/** The NeoForge entry point: everything here only connects NeoForge to the shared code. */ +@Mod(MCHeli.MOD_ID) +public final class McheliNeoForge { + + public McheliNeoForge(IEventBus modBus) { + Platform.install(new NeoForgePlatform()); + MCHeli.init(); + + modBus.addListener(RegisterEvent.class, event -> { + for (ResourceKey> registry : Registration.registries()) { + register(event, Registration.typed(registry)); + } + event.register(NeoForgeRegistries.Keys.INGREDIENT_TYPES, + helper -> helper.register(VehicleIngredient.ID, NeoForgeVehicleIngredient.TYPE)); + }); + modBus.addListener(RegisterPayloadHandlersEvent.class, McheliNeoForge::registerPackets); + modBus.addListener(AddPackFindersEvent.class, event -> event.addRepositorySource(CommonHooks.packSource())); + + NeoForge.EVENT_BUS.addListener(ServerAboutToStartEvent.class, event -> CommonHooks.serverAboutToStart(event.getServer())); + NeoForge.EVENT_BUS.addListener(ServerStoppedEvent.class, event -> CommonHooks.serverStopped()); + NeoForge.EVENT_BUS.addListener(ServerTickEvent.Post.class, event -> CommonHooks.serverTickEnd(event.getServer())); + NeoForge.EVENT_BUS.addListener(RegisterCommandsEvent.class, event -> CommonHooks.registerCommands(event.getDispatcher())); + NeoForge.EVENT_BUS.addListener(PlayerEvent.PlayerLoggedInEvent.class, event -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedIn(player); + } + }); + NeoForge.EVENT_BUS.addListener(PlayerEvent.PlayerLoggedOutEvent.class, event -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedOut(player); + } + }); + + if (Platform.get().isClient()) { + NeoForgeClient.init(modBus); + } + } + + /** Only once NeoForge asks for that registry, with our entries in declaration order. */ + private static void register(RegisterEvent event, ResourceKey> registry) { + if (event.getRegistryKey().equals(registry)) { + event.register(registry, helper -> Registration.registerAll(registry, helper::register)); + } + } + + /** Handlers run on the main thread, which is NeoForge's default. */ + private static void registerPackets(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(Integer.toString(ModNetwork.PROTOCOL_VERSION)); + for (ModNetwork.Serverbound packet : ModNetwork.SERVERBOUND) { + serverbound(registrar, packet); + } + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + clientbound(registrar, packet); + } + } + + private static void serverbound(PayloadRegistrar registrar, ModNetwork.Serverbound packet) { + registrar.playToServer(packet.type(), packet.codec(), + (msg, context) -> packet.handler().accept(msg, (ServerPlayer) context.player())); + } + + private static void clientbound(PayloadRegistrar registrar, ModNetwork.Clientbound packet) { + registrar.playToClient(packet.type(), packet.codec(), (msg, context) -> packet.handler().accept(msg)); + } +} diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 0000000..31b238a --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,65 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.EntityRenderersEvent; +import net.neoforged.neoforge.client.event.InputEvent; +import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent; +import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import net.neoforged.neoforge.client.event.RegisterParticleProvidersEvent; +import net.neoforged.neoforge.client.event.RenderHandEvent; +import net.neoforged.neoforge.client.event.RenderLivingEvent; +import net.neoforged.neoforge.client.gui.VanillaGuiLayers; +import net.neoforged.neoforge.common.NeoForge; + +/** + * Client events on NeoForge. Only loaded on a physical client. The camera hook is a mixin + * ({@code GameRendererMixin}): NeoForge's camera event fires before vanilla positions the camera, + * so a cockpit eye set from it would be overwritten. + */ +final class NeoForgeClient { + private NeoForgeClient() { + } + + static void init(IEventBus modBus) { + ClientHooks.init(); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(RegisterParticleProvidersEvent.class, + event -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); + modBus.addListener(RegisterKeyMappingsEvent.class, event -> ClientHooks.keyMappings().forEach(event::register)); + modBus.addListener(RegisterClientReloadListenersEvent.class, + event -> ClientHooks.reloadListeners().values().forEach(event::registerReloadListener)); + modBus.addListener(RegisterGuiLayersEvent.class, + event -> event.registerAbove(VanillaGuiLayers.SLEEP_OVERLAY, ClientHooks.HUD_LAYER, ClientHooks::drawHud)); + + NeoForge.EVENT_BUS.addListener(ClientTickEvent.Post.class, event -> ClientHooks.clientTickEnd()); + NeoForge.EVENT_BUS.addListener(RenderHandEvent.class, event -> event.setCanceled(ClientHooks.hidesHand())); + NeoForge.EVENT_BUS.addListener(RenderLivingEvent.Pre.class, + event -> ClientHooks.beforeRenderLiving(event.getEntity(), event.getPoseStack(), event.getPartialTick())); + NeoForge.EVENT_BUS.addListener(InputEvent.InteractionKeyMappingTriggered.class, NeoForgeClient::onInteractionKey); + NeoForge.EVENT_BUS.addListener(ClientPlayerNetworkEvent.LoggingIn.class, event -> ClientHooks.loggedIn(event.getPlayer())); + NeoForge.EVENT_BUS.addListener(ClientPlayerNetworkEvent.LoggingOut.class, event -> ClientHooks.loggedOut()); + } + + private static void onInteractionKey(InputEvent.InteractionKeyMappingTriggered event) { + if (event.isUseItem()) { + switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> { + } + case CONSUME -> { + event.setSwingHand(false); + event.setCanceled(true); + } + case CONSUME_AND_SWING -> event.setCanceled(true); + } + } else if (event.isPickBlock() && ClientHooks.onPickBlockKey()) { + event.setCanceled(true); + } + } +} diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 0000000..ab35358 --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.ModList; +import net.neoforged.fml.loading.FMLEnvironment; +import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.neoforge.network.PacketDistributor; + +import java.nio.file.Path; + +final class NeoForgePlatform implements Platform { + @Override + public String loaderName() { + return "neoforge"; + } + + @Override + public Path gameDir() { + return FMLPaths.GAMEDIR.get(); + } + + @Override + public Path configDir() { + return FMLPaths.CONFIGDIR.get(); + } + + @Override + public String modVersion() { + return ModList.get().getModContainerById(MCHeli.MOD_ID) + .map(c -> c.getModInfo().getVersion().toString()).orElse(""); + } + + @Override + public boolean isClient() { + return FMLEnvironment.dist == Dist.CLIENT; + } + + @Override + public void sendToServer(CustomPacketPayload payload) { + PacketDistributor.sendToServer(payload); + } + + @Override + public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { + PacketDistributor.sendToPlayer(player, payload); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 0000000..0e7ddc9 --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java @@ -0,0 +1,35 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.item.VehicleIngredient; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.common.crafting.ICustomIngredient; +import net.neoforged.neoforge.common.crafting.IngredientType; + +import java.util.stream.Stream; + +/** {@link VehicleIngredient} as a NeoForge ingredient, read from the {@code neoforge:ingredient_type} key. */ +record NeoForgeVehicleIngredient(String vehicle) implements ICustomIngredient { + static final IngredientType TYPE = new IngredientType<>( + VehicleIngredient.codec(NeoForgeVehicleIngredient::new, NeoForgeVehicleIngredient::vehicle), + VehicleIngredient.streamCodec(NeoForgeVehicleIngredient::new, NeoForgeVehicleIngredient::vehicle)); + + @Override + public boolean test(ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public Stream getItems() { + return VehicleIngredient.displayStacks(vehicle); + } + + @Override + public boolean isSimple() { + return false; + } + + @Override + public IngredientType getType() { + return TYPE; + } +} diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java new file mode 100644 index 0000000..4c289d0 --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java @@ -0,0 +1,12 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.Camera; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** NeoForge adds a roll to {@code Camera#setRotation}, but leaves it protected. */ +@Mixin(Camera.class) +public interface CameraInvoker { + @Invoker("setRotation") + void mcheli$setRotation(float yRot, float xRot, float roll); +} diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java new file mode 100644 index 0000000..bfc4308 --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java @@ -0,0 +1,35 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * The camera hook, where Forge fires its camera angle event on 1.21.1: once the camera is placed + * for the frame, just before its rotation becomes the view matrix. NeoForge fires its own event + * before vanilla sets the camera position, which would undo a cockpit eye. + */ +@Mixin(GameRenderer.class) +abstract class GameRendererMixin { + @Shadow + @Final + private Camera mainCamera; + + @Inject(method = "renderLevel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;rotation()Lorg/joml/Quaternionf;", + ordinal = 0)) + private void mcheli$beforeViewRotation(DeltaTracker deltaTracker, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, mainCamera.getPartialTickTime(), GameRendererMixin::setRotation); + } + + /** NeoForge turns the camera the other way round its roll axis than Forge does. */ + private static void setRotation(Camera camera, float yaw, float pitch, float roll) { + ((CameraInvoker) camera).mcheli$setRotation(yaw, pitch, -roll); + } +} diff --git a/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..ecb64df --- /dev/null +++ b/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,36 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="${mod_license}" +issueTrackerURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge/issues" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" +logoFile="mcheli_logo.png" +authors="${mod_authors}" +credits="EMB4 created the original MCHeli mod and all of its vehicle content. Fureniku maintains the CurseForge project." +description=''' +Helicopters, planes, tanks and vehicles for modern Minecraft. +A ground-up rework of MCHeli 1.0.3 by EMB4, which stopped at Minecraft 1.7.10. +The vehicles come from your own copy of the original: put MC1.7.10_mcheli_1.0.3.zip from the +MCHeli CurseForge page into the "mcheli" folder of the game directory. +''' + +[[mixins]] +config="mcheli.mixins.json" + +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="${loader_version_range}" + ordering="NONE" + side="BOTH" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="BOTH" diff --git a/neoforge-1.21.1/src/main/resources/mcheli.mixins.json b/neoforge-1.21.1/src/main/resources/mcheli.mixins.json new file mode 100644 index 0000000..19d6b73 --- /dev/null +++ b/neoforge-1.21.1/src/main/resources/mcheli.mixins.json @@ -0,0 +1,13 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraInvoker", + "GameRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/neoforge-26.3/build.gradle b/neoforge-26.3/build.gradle new file mode 100644 index 0000000..f57e988 --- /dev/null +++ b/neoforge-26.3/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java' + id 'idea' + id 'eclipse' + id 'net.neoforged.moddev' +} + +apply from: rootProject.file('gradle/loader.gradle') + +// The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this +// one (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +neoForge { + version = neoforge_version + + runs { + configureEach { + gameDirectory = rootProject.file('run') + systemProperty 'mcheli.legacyZip', legacyZip + } + client { + client() + } + server { + server() + programArgument '--nogui' + } + } + + mods { + mcheli { + sourceSet sourceSets.main + } + } +} + +repositories { + mavenCentral() +} diff --git a/neoforge-26.3/gradle.properties b/neoforge-26.3/gradle.properties new file mode 100644 index 0000000..7645c93 --- /dev/null +++ b/neoforge-26.3/gradle.properties @@ -0,0 +1,7 @@ +# Minecraft 26.3 on NeoForge +loader=neoforge +minecraft_version=26.3 +minecraft_version_range=[26.3,26.4) +neoforge_version=26.3.0.99 +loader_version_range=[26.3,) +java_version=25 diff --git a/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java new file mode 100644 index 0000000..734b0ed --- /dev/null +++ b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -0,0 +1,91 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.CommonHooks; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.item.VehicleIngredient; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.Registry; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.AddPackFindersEvent; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.server.ServerAboutToStartEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import net.neoforged.neoforge.registries.NeoForgeRegistries; +import net.neoforged.neoforge.registries.RegisterEvent; + +/** The NeoForge entry point: everything here only connects NeoForge to the shared code. */ +@Mod(MCHeli.MOD_ID) +public final class McheliNeoForge { + + public McheliNeoForge(IEventBus modBus) { + Platform.install(new NeoForgePlatform()); + MCHeli.init(); + + modBus.addListener(RegisterEvent.class, event -> { + for (ResourceKey> registry : Registration.registries()) { + register(event, Registration.typed(registry)); + } + event.register(NeoForgeRegistries.Keys.INGREDIENT_TYPES, + helper -> helper.register(VehicleIngredient.ID, NeoForgeVehicleIngredient.TYPE)); + }); + modBus.addListener(RegisterPayloadHandlersEvent.class, McheliNeoForge::registerPackets); + modBus.addListener(AddPackFindersEvent.class, event -> event.addRepositorySource(CommonHooks.packSource())); + + NeoForge.EVENT_BUS.addListener(ServerAboutToStartEvent.class, event -> CommonHooks.serverAboutToStart(event.getServer())); + NeoForge.EVENT_BUS.addListener(ServerStoppedEvent.class, event -> CommonHooks.serverStopped()); + NeoForge.EVENT_BUS.addListener(ServerTickEvent.Post.class, event -> CommonHooks.serverTickEnd(event.getServer())); + NeoForge.EVENT_BUS.addListener(RegisterCommandsEvent.class, event -> CommonHooks.registerCommands(event.getDispatcher())); + NeoForge.EVENT_BUS.addListener(PlayerEvent.PlayerLoggedInEvent.class, event -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedIn(player); + } + }); + NeoForge.EVENT_BUS.addListener(PlayerEvent.PlayerLoggedOutEvent.class, event -> { + if (event.getEntity() instanceof ServerPlayer player) { + CommonHooks.playerLoggedOut(player); + } + }); + + if (Platform.get().isClient()) { + NeoForgeClient.init(modBus); + } + } + + /** Only once NeoForge asks for that registry, with our entries in declaration order. */ + private static void register(RegisterEvent event, ResourceKey> registry) { + if (event.getRegistryKey().equals(registry)) { + event.register(registry, helper -> Registration.registerAll(registry, helper::register)); + } + } + + /** Handlers run on the main thread, which is NeoForge's default. */ + private static void registerPackets(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(Integer.toString(ModNetwork.PROTOCOL_VERSION)); + for (ModNetwork.Serverbound packet : ModNetwork.SERVERBOUND) { + serverbound(registrar, packet); + } + for (ModNetwork.Clientbound packet : ModNetwork.CLIENTBOUND) { + clientbound(registrar, packet); + } + } + + private static void serverbound(PayloadRegistrar registrar, ModNetwork.Serverbound packet) { + registrar.playToServer(packet.type(), packet.codec(), + (msg, context) -> packet.handler().accept(msg, (ServerPlayer) context.player())); + } + + private static void clientbound(PayloadRegistrar registrar, ModNetwork.Clientbound packet) { + registrar.playToClient(packet.type(), packet.codec(), (msg, context) -> packet.handler().accept(msg)); + } +} diff --git a/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 0000000..1cd8698 --- /dev/null +++ b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,64 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.EntityRenderersEvent; +import net.neoforged.neoforge.client.event.InputEvent; +import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent; +import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import net.neoforged.neoforge.client.event.RegisterParticleProvidersEvent; +import net.neoforged.neoforge.client.event.RenderHandEvent; +import net.neoforged.neoforge.client.event.RenderLivingEvent; +import net.neoforged.neoforge.client.gui.VanillaGuiLayers; +import net.neoforged.neoforge.common.NeoForge; + +/** + * Client events on NeoForge. Only loaded on a physical client. The camera hook is a mixin + * ({@code GameRendererMixin}): NeoForge's camera event fires before vanilla positions the camera, + * so a cockpit eye set from it would be overwritten. + */ +final class NeoForgeClient { + private NeoForgeClient() { + } + + static void init(IEventBus modBus) { + ClientHooks.init(); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(RegisterParticleProvidersEvent.class, + event -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); + modBus.addListener(RegisterKeyMappingsEvent.class, event -> ClientHooks.keyMappings().forEach(event::register)); + modBus.addListener(AddClientReloadListenersEvent.class, event -> ClientHooks.reloadListeners().forEach(event::addListener)); + modBus.addListener(RegisterGuiLayersEvent.class, + event -> event.registerAbove(VanillaGuiLayers.SLEEP_OVERLAY, ClientHooks.HUD_LAYER, ClientHooks::drawHud)); + + NeoForge.EVENT_BUS.addListener(ClientTickEvent.Post.class, event -> ClientHooks.clientTickEnd()); + NeoForge.EVENT_BUS.addListener(RenderHandEvent.class, event -> event.setCanceled(ClientHooks.hidesHand())); + NeoForge.EVENT_BUS.addListener(RenderLivingEvent.Pre.class, + event -> ClientHooks.beforeRenderLiving(event.getRenderState(), event.getPoseStack(), event.getPartialTick())); + NeoForge.EVENT_BUS.addListener(InputEvent.InteractionKeyMappingTriggered.class, NeoForgeClient::onInteractionKey); + NeoForge.EVENT_BUS.addListener(ClientPlayerNetworkEvent.LoggingIn.class, event -> ClientHooks.loggedIn(event.getPlayer())); + NeoForge.EVENT_BUS.addListener(ClientPlayerNetworkEvent.LoggingOut.class, event -> ClientHooks.loggedOut()); + } + + private static void onInteractionKey(InputEvent.InteractionKeyMappingTriggered event) { + if (event.isUseItem()) { + switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> { + } + case CONSUME -> { + event.setSwingHand(false); + event.setCanceled(true); + } + case CONSUME_AND_SWING -> event.setCanceled(true); + } + } else if (event.isPickBlock() && ClientHooks.onPickBlockKey()) { + event.setCanceled(true); + } + } +} diff --git a/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 0000000..6cd0660 --- /dev/null +++ b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java @@ -0,0 +1,59 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTab; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.ModList; +import net.neoforged.fml.loading.FMLEnvironment; +import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; +import net.neoforged.neoforge.network.PacketDistributor; + +import java.nio.file.Path; + +final class NeoForgePlatform implements Platform { + @Override + public String loaderName() { + return "neoforge"; + } + + @Override + public Path gameDir() { + return FMLPaths.GAMEDIR.get(); + } + + @Override + public Path configDir() { + return FMLPaths.CONFIGDIR.get(); + } + + @Override + public String modVersion() { + return ModList.get().getModContainerById(MCHeli.MOD_ID) + .map(c -> c.getModInfo().getVersion().toString()).orElse(""); + } + + @Override + public boolean isClient() { + return FMLEnvironment.getDist() == Dist.CLIENT; + } + + @Override + public void sendToServer(CustomPacketPayload payload) { + ClientPacketDistributor.sendToServer(payload); + } + + @Override + public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { + PacketDistributor.sendToPlayer(player, payload); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 0000000..195be00 --- /dev/null +++ b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.item.VehicleIngredient; +import net.minecraft.core.Holder; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.neoforged.neoforge.common.crafting.ICustomIngredient; +import net.neoforged.neoforge.common.crafting.IngredientType; + +import java.util.stream.Stream; + +/** {@link VehicleIngredient} as a NeoForge ingredient, read from the {@code neoforge:ingredient_type} key. */ +record NeoForgeVehicleIngredient(String vehicle) implements ICustomIngredient { + static final IngredientType TYPE = new IngredientType<>( + VehicleIngredient.codec(NeoForgeVehicleIngredient::new, NeoForgeVehicleIngredient::vehicle), + VehicleIngredient.streamCodec(NeoForgeVehicleIngredient::new, NeoForgeVehicleIngredient::vehicle)); + + @Override + public boolean test(ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public Stream> items() { + return VehicleIngredient.items(); + } + + @Override + public boolean isSimple() { + return false; + } + + @Override + public IngredientType getType() { + return TYPE; + } + + @Override + public SlotDisplay display() { + return VehicleIngredient.display(vehicle); + } +} diff --git a/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java new file mode 100644 index 0000000..4c289d0 --- /dev/null +++ b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java @@ -0,0 +1,12 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.Camera; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** NeoForge adds a roll to {@code Camera#setRotation}, but leaves it protected. */ +@Mixin(Camera.class) +public interface CameraInvoker { + @Invoker("setRotation") + void mcheli$setRotation(float yRot, float xRot, float roll); +} diff --git a/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java new file mode 100644 index 0000000..a076be4 --- /dev/null +++ b/neoforge-26.3/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java @@ -0,0 +1,39 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * The camera hook, at the two places Forge fires its camera angle event: once vanilla has placed + * the camera for the frame, and again before the camera is copied into the render state. + */ +@Mixin(GameRenderer.class) +abstract class GameRendererMixin { + @Shadow + @Final + private Camera mainCamera; + + @Inject(method = "update", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;update(Lnet/minecraft/client/DeltaTracker;)V", + shift = At.Shift.AFTER)) + private void mcheli$afterCameraUpdate(DeltaTracker deltaTracker, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, deltaTracker.getGameTimeDeltaPartialTick(true), GameRendererMixin::setRotation); + } + + @Inject(method = "extractCamera", at = @At("HEAD")) + private void mcheli$beforeExtractCamera(DeltaTracker deltaTracker, float worldPartialTicks, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, worldPartialTicks, GameRendererMixin::setRotation); + } + + /** NeoForge turns the camera the other way round its roll axis than Forge does. */ + private static void setRotation(Camera camera, float yaw, float pitch, float roll) { + ((CameraInvoker) camera).mcheli$setRotation(yaw, pitch, -roll); + } +} diff --git a/neoforge-26.3/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-26.3/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..c809591 --- /dev/null +++ b/neoforge-26.3/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,36 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="${mod_license}" +issueTrackerURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge/issues" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" +iconFile="mcheli_logo.png" +authors="${mod_authors}" +credits="EMB4 created the original MCHeli mod and all of its vehicle content. Fureniku maintains the CurseForge project." +description=''' +Helicopters, planes, tanks and vehicles for modern Minecraft. +A ground-up rework of MCHeli 1.0.3 by EMB4, which stopped at Minecraft 1.7.10. +The vehicles come from your own copy of the original: put MC1.7.10_mcheli_1.0.3.zip from the +MCHeli CurseForge page into the "mcheli" folder of the game directory. +''' + +[[mixins]] +config="mcheli.mixins.json" + +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="${loader_version_range}" + ordering="NONE" + side="BOTH" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="BOTH" diff --git a/neoforge-26.3/src/main/resources/mcheli.mixins.json b/neoforge-26.3/src/main/resources/mcheli.mixins.json new file mode 100644 index 0000000..19d6b73 --- /dev/null +++ b/neoforge-26.3/src/main/resources/mcheli.mixins.json @@ -0,0 +1,13 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraInvoker", + "GameRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/settings.gradle b/settings.gradle index ad88f52..141df28 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,19 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { url = 'https://maven.fabricmc.net/' } + maven { url = 'https://maven.neoforged.net/releases/' } + } + resolutionStrategy { + eachPlugin { + // Loom publishes every plugin id from one artifact, without plugin markers. + if (requested.id.id.startsWith('net.fabricmc.fabric-loom')) { + useModule("net.fabricmc:fabric-loom:${requested.version}") + } + } + } +} + plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' } @@ -7,8 +23,9 @@ rootProject.name = 'mcheli-rework' // Version-free code: content, models, HUD scripts, flight maths. No Minecraft on its classpath. include 'common' -// One module per Minecraft version, each compiling common's sources together with its own -// rendering, networking, registration and event code. See docs/DEVELOPMENT.md. +// One module per loader and Minecraft version, each compiling common's sources and its Minecraft +// version's shared sources together with its own registration, events and networking. See +// docs/DEVELOPMENT.md. for (String platform : providers.gradleProperty('platforms').get().split(',')) { include platform.trim() }