diff --git a/CHANGELOG.md b/CHANGELOG.md index 81dd8409..04a236e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). ## [Unreleased] +## [1.1.0-beta.9] - 2026-11-07 + +Phase 13 of the road to 1.1.0: every loader on every supported version. This beta ships 21 jars, one per +loader and Minecraft version, from 1.20.6 to 26.3. Betas are not uploaded to CurseForge. Worlds from +1.1.0-beta.8.1 load as before, and the network protocol is unchanged (11). Only the Forge 26.3 jar has been +started from a launcher profile so far, and nobody has flown on the new jars yet; see the smoke test in +[docs/TESTING.md](docs/TESTING.md) before relying on one. + +### Added + +- NeoForge and Fabric on Minecraft 26.3 catch up with Forge: everything from phases 8 to 12 now runs + there, from the drafting table and vehicle screen to smoke and sight zoom. +- Minecraft 1.21.1 on Forge, NeoForge and Fabric gets everything from phases 8 to 12: fuel, equipment, + the vehicle screen, the drafting table, team manager, UAV stations and the workshop. On 1.21.1 the PVP + button of the team manager switches the server's PVP setting until a restart, since that version has + no game rule for it, and target markers ignore view bobbing. +- Minecraft 26.2, 26.1, 1.21.11, 1.21.5 and 1.20.6 on Forge, NeoForge and Fabric, with everything 26.3 has. On + 26.1 and older, teams pick from the sixteen chat colours, since those versions have no separate team + colours. On 1.21.5 and 1.20.6 the PVP button of the team manager switches the server's PVP setting until a + restart. + +### Changed + +- Forge, NeoForge and Fabric on 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1 and 1.20.6 are all built on every pull request. +- 1.1.0 ships on 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1 and 1.20.6 on all three loaders (21 jars). + 1.20.2 and 1.19.4 are on hold and not part of it. +- Clients and servers on different loaders cannot play together, which the roadmap used to promise. + Each loader refuses the other at its own handshake, before the mod runs. A pack zip still works on + every loader when copied by hand. + +### Fixed + +- Fabric clients crashed at startup on 26.3 and 1.21.1, because `RollableCamera` sat in the mixin package. +- Glow particles were invisible on Fabric 1.21.1: vanilla only draws the particle types on its own list. +- On 1.21.1 the E-767 recipe failed to load, since its UAV station ingredient used the newer format. +- On Forge 1.21.1, recipes that use gunpowder or feathers could not be crafted, since Forge names those + tags differently from the conventional ones our recipes use. The Forge modules on 1.21.x now define them. +- Sounds that come from a content pack or the workshop were silent on Forge 26.3, engine loops and + one-shots alike, and left a sound channel open. Forge's sound engine on 1.21.11 and later never asks the + sound for its stream, so the file on disk was never read. Forge 1.21.11, 26.1, 26.2 and 26.3 now read it + from the sound engine's own library. + ## [1.1.0-beta.8.1] - 2026-11-06 A hotfix for the Forge 26.3 jar, with no other changes. Worlds from 1.1.0-beta.8 load as before, and @@ -718,6 +760,7 @@ Phase 1: foundation and flyable helicopters. - Free look moved from Left Ctrl to Left Alt, because Left Ctrl is now the vanilla sprint key. - Roll damping runs per tick instead of per frame, so handling no longer depends on frame rate. +[1.1.0-beta.9]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.9 [1.1.0-beta.8.1]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.8.1 [1.1.0-beta.8]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.8 [1.1.0-beta.7]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.7 diff --git a/README.md b/README.md index f7ebf37a..ca4e18c0 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,21 @@ MCHeli Rework icon -Helicopters, planes, tanks and ground vehicles for **Minecraft 26.3** and **1.21.1**, on **Forge**, -**NeoForge** and **Fabric**. +Helicopters, planes, tanks and ground vehicles for **Minecraft 26.3**, **26.2**, **26.1**, +**1.21.11**, **1.21.5**, **1.21.1** and **1.20.6**, 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, textures and sounds load unchanged. -> **Status:** on the way to 1.1.0, now at phase 12 (`1.1.0-beta.8.1`). Every helicopter, plane, +> **Status:** on the way to 1.1.0, now at phase 13 (`1.1.0-beta.9`). Every helicopter, plane, > tank, car and ground vehicle can be placed, crewed and driven with the original handling, all > 229 weapons fire, and the scripted HUDs, fuel, the vehicle screen and all of the original's > equipment are in, down to UAV stations, the drafting table and the team manager. The vehicle > workshop builds new vehicles in game from a Blockbench or Blender model, with their own textures -> and sounds, and sends them to a server for its operators to approve. Betas ship the -> Forge 26.3 jar only; 1.1.0 ships on Forge and Fabric for every version from 1.19.4 to 26.3, and -> on NeoForge from 1.20.6 (see [supported versions](docs/ROADMAP.md#supported-versions)). See the -> [roadmap](docs/ROADMAP.md). +> and sounds, and sends them to a server for its operators to approve. From `1.1.0-beta.9` on, +> every jar ships: Forge, NeoForge and Fabric for every version from 1.20.6 to 26.3 (see +> [supported versions](docs/ROADMAP.md#supported-versions)). See the [roadmap](docs/ROADMAP.md). ## Showcase video @@ -84,16 +83,21 @@ Reserved original, the code is source available rather than open source. ## Requirements -| | 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) | +| Minecraft | Forge | NeoForge | Fabric | Java | +|---|---|---|---|---| +| 26.3 | 66.1.0 or newer in 66.x | 26.3.0.99 or newer | Loader 0.20 and Fabric API | 25 | +| 26.2 | 65.1.5 or newer in 65.x | 26.2.0.93 or newer | Loader 0.20 and Fabric API | 25 | +| 26.1 | 62.0.9 or newer in 62.x | 26.1.2.126 or newer | Loader 0.20 and Fabric API | 25 | +| 1.21.11 | 61.2.4 or newer in 61.x | 21.11.48 or newer | Loader 0.16 and Fabric API | 21 | +| 1.21.5 | 55.1.18 or newer in 55.x | 21.5.101 or newer | Loader 0.16 and Fabric API | 21 | +| 1.21.1 | 52.1.0 or newer | 21.1.200 or newer | Loader 0.16 and Fabric API | 21 | +| 1.20.6 | 50.2.14 or newer in 50.x | 20.6.144 or newer | Loader 0.16 and Fabric API | 21 | + +The launcher bundles the right Java for each version. ## Installing -1. Install Forge, NeoForge or Fabric for Minecraft 26.3 or 1.21.1. On Fabric, also install +1. Install Forge, NeoForge or Fabric for Minecraft 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1 or 1.20.6. 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): @@ -118,7 +122,8 @@ zip goes. 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. +and configs are the same files on all three. Do not open a world in an older Minecraft version than +the one that last saved it. ## Playing diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java b/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java index 0753c4fc..a68acc34 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ImportTarget.java @@ -15,5 +15,14 @@ public enum ImportTarget { * 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 + V1_21_1, + /** + * Minecraft 1.20.6: the 1.21.1 layout, except that data folders still have their plural names. + */ + V1_20_6; + + /** The folder under {@code data/} that holds recipes on this target. */ + public String recipeFolder() { + return this == V1_20_6 ? "recipes" : "recipe"; + } } 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 50c50552..1f87a033 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.throwable.ThrowableDefinition; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.recipe.LegacyRecipe; @@ -47,12 +48,12 @@ 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. 3: vehicle ingredients name their type for every loader, and * the output depends on the {@link ImportTarget}. 4: recipes may use the equipment - * items, so the E-767 is crafted with a UAV station. + * items, so the E-767 is crafted with a UAV station. 5: on 1.21.1, smoke grenades get icon numbers too. */ - public static final int FORMAT = 4; + public static final int FORMAT = 5; /** - * Written at the pack root for {@link ImportTarget#V1_21_1}: each original vehicle's + * Written at the pack root for every target but {@link ImportTarget#MODERN}: 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"; @@ -77,7 +78,7 @@ 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"); + this.recipesOut = packRoot.resolve("data/mcheli/" + target.recipeFolder()); } /** @@ -116,7 +117,7 @@ private Summary importFrom(Path zipPath) throws IOException { writeTranslations(); ContentRegistry content = ContentRegistry.load(assetsOut); - Map icons = target == ImportTarget.V1_21_1 ? writeKindModels(content) : Map.of(); + Map icons = target != ImportTarget.MODERN ? 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()); @@ -289,6 +290,9 @@ private Map writeKindModels(ContentRegistry content) throws IOE for (AircraftDefinition def : content.all()) { byKind.computeIfAbsent(def.kind(), k -> new ArrayList<>()).add(def.name()); } + for (ThrowableDefinition def : content.throwables()) { + byKind.computeIfAbsent("throwable", k -> new ArrayList<>()).add(def.name()); + } Map icons = new TreeMap<>(); for (Map.Entry> e : byKind.entrySet()) { List names = e.getValue(); 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 5b47de97..a027206b 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 @@ -199,7 +199,7 @@ private static void appendResult(StringBuilder json, Vehicle result, int count, 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(); + case V1_21_1, V1_20_6 -> "\"minecraft:custom_model_data\": " + result.icon(); }; } @@ -208,7 +208,7 @@ private static String ingredient(String name, int damage, Predicate modI String lower = name.trim().toLowerCase(Locale.ROOT); if (lower.startsWith("mcheli:")) { if (EQUIPMENT_ITEMS.contains(lower)) { - return '"' + lower + '"'; + return target == ImportTarget.MODERN ? '"' + lower + '"' : "{\"item\": \"" + lower + "\"}"; } if (!modItems.test(lower)) { throw new InvalidRecipeException(lower + " does not exist (yet)"); diff --git a/common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java b/common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java index 45cba216..16853082 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java +++ b/common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.physics; +import org.joml.Matrix3d; import org.joml.Quaternionf; import org.joml.Vector3f; @@ -12,6 +13,9 @@ * {@code rotY(-yaw) * rotX(pitch) * rotZ(roll)}. */ public final class AircraftRotation { + /** Below this cosine of the middle angle, {@link #rollInXyzOrder} treats the first and last as one axis. */ + private static final double GIMBAL_LOCK = 1.0E-9; + private AircraftRotation() { } @@ -69,6 +73,39 @@ public static float cameraRoll(float yaw, float pitch, float roll, float viewYaw return (float) Math.toDegrees(Math.atan2(onLeft, onUp)); } + /** + * Angles for a view matrix built as {@code rotationXYZ(pitch, yaw + PI, roll)}, the way Forge + * builds it on Minecraft 1.20.6, so that the matrix comes out as a camera roll stacked on top of + * the other two rotations. Fed the plain angles that matrix applies the roll around the world's + * Z axis instead: a bank would look like a bank going south, the opposite one going north, and a + * change of pitch going east or west. + * + * @param yaw the camera yaw, in degrees + * @param pitch the camera pitch, in degrees + * @param roll the roll as {@link #cameraRoll} returns it, in degrees + * @return {@code {yaw, pitch, roll}} in degrees, to pass on to that matrix + */ + public static float[] rollInXyzOrder(float yaw, float pitch, float roll) { + Matrix3d view = new Matrix3d() + .rotationZ(Math.toRadians(-roll)) + .rotateX(Math.toRadians(pitch)) + .rotateY(Math.toRadians(yaw) + Math.PI); + // The matrix above equals rotX(x) * rotY(y) * rotZ(z); read those three back from it. + double cosY = Math.hypot(view.m00, view.m10); + double x; + double z; + if (cosY > GIMBAL_LOCK) { + x = Math.atan2(-view.m21, view.m22); + z = Math.atan2(-view.m10, view.m00); + } else { + // Looking along X: the first and last rotation turn around the same axis, so Z can stay 0. + x = Math.atan2(view.m12, view.m11); + z = 0.0; + } + double y = Math.atan2(view.m20, cosY); + return new float[]{(float) Math.toDegrees(y - Math.PI), (float) Math.toDegrees(x), (float) Math.toDegrees(z)}; + } + public static float wrap(float degrees) { float d = degrees % 360.0F; if (d >= 180.0F) { 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 index 01ce2086..9167c9cc 100644 --- a/common/src/test/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipeTargetTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipeTargetTest.java @@ -59,6 +59,14 @@ void minecraft1211RecipesUseObjectsAndCustomModelData() { """, LegacyRecipe.toJson(AH_6X, LINE, true, "mcheli:ah-6"::equals, ImportTarget.V1_21_1)); } + @Test + void equipmentIngredientsFollowTheTarget() { + assertTrue(LegacyRecipe.toJson(AH_6X, "mcheli:uav_station,coal", false, name -> false, ImportTarget.V1_21_1) + .contains("{\"item\": \"mcheli:uav_station\"}")); + assertTrue(LegacyRecipe.toJson(AH_6X, "mcheli:uav_station,coal", false, name -> false, ImportTarget.MODERN) + .contains("\"mcheli:uav_station\"")); + } + @Test void minecraft1211ShapelessIngredientsAreObjectsToo() { String json = LegacyRecipe.toJson(AH_6X, "iron_block,coal", false, name -> false, ImportTarget.V1_21_1); diff --git a/common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java b/common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java index 2e8c3839..2a9bceb7 100644 --- a/common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java @@ -1,10 +1,12 @@ package com.jnhprojects.mcheli.physics; +import org.joml.Matrix4f; import org.joml.Quaternionf; import org.joml.Vector3f; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Checks the camera tilt against the hull. The camera rotation is rebuilt the way @@ -68,4 +70,56 @@ void cameraUpStaysOnTheHullsSideOfTheView() { assertEquals(expected.y, cameraUp.y, EPSILON); assertEquals(expected.z, cameraUp.z, EPSILON); } + + /** The view matrix Forge builds on Minecraft 1.20.6 from what its camera angle event returns. */ + private static Matrix4f forgeView(float[] angles) { + return new Matrix4f().rotationXYZ((float) Math.toRadians(angles[1]), (float) (Math.toRadians(angles[0]) + Math.PI), (float) Math.toRadians(angles[2])); + } + + /** The inverse of {@link #camera}: what every other version builds as its view matrix. */ + private static Matrix4f expectedView(float yaw, float pitch, float roll) { + return new Matrix4f().rotation(camera(yaw, pitch, roll).conjugate()); + } + + private static void assertSameRotation(Matrix4f expected, Matrix4f actual) { + Vector3f[] axes = {new Vector3f(1.0F, 0.0F, 0.0F), new Vector3f(0.0F, 1.0F, 0.0F), new Vector3f(0.0F, 0.0F, 1.0F)}; + for (Vector3f axis : axes) { + Vector3f a = expected.transformDirection(new Vector3f(axis)); + Vector3f b = actual.transformDirection(new Vector3f(axis)); + assertEquals(a.x, b.x, EPSILON); + assertEquals(a.y, b.y, EPSILON); + assertEquals(a.z, b.z, EPSILON); + } + } + + @Test + void rollInXyzOrderBuildsTheSameViewOnEveryHeading() { + for (float yaw = -180.0F; yaw <= 180.0F; yaw += 15.0F) { + for (float pitch = -80.0F; pitch <= 80.0F; pitch += 20.0F) { + for (float roll = -170.0F; roll <= 170.0F; roll += 34.0F) { + assertSameRotation(expectedView(yaw, pitch, roll), forgeView(AircraftRotation.rollInXyzOrder(yaw, pitch, roll))); + } + } + } + } + + @Test + void rollInXyzOrderSurvivesGimbalLock() { + // Due east or west and level, a hair of roll leaves the middle angle at its limit. + for (float yaw : new float[]{-90.0F, 90.0F}) { + for (float roll : new float[]{0.0F, 1.0E-4F, -1.0E-4F, 1.0F}) { + assertSameRotation(expectedView(yaw, 0.0F, roll), forgeView(AircraftRotation.rollInXyzOrder(yaw, 0.0F, roll))); + } + } + } + + @Test + void forgesPlainAnglesWouldBeWrong() { + // Why rollInXyzOrder exists: going east, the plain angles do not give the same view at all. + Matrix4f expected = expectedView(-90.0F, 0.0F, -30.0F); + Matrix4f plain = forgeView(new float[]{-90.0F, 0.0F, -30.0F}); + Vector3f expectedUp = expected.transformDirection(new Vector3f(0.0F, 1.0F, 0.0F)); + Vector3f plainUp = plain.transformDirection(new Vector3f(0.0F, 1.0F, 0.0F)); + assertTrue(expectedUp.distance(plainUp) > 0.1F); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 340758b4..d86edf6d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,7 +51,7 @@ loader. The top layer is a thin module per loader and Minecraft version, which c lower layers together with its own glue into one jar. See [DEVELOPMENT.md](DEVELOPMENT.md#three-layers). -Only 26.3 and 1.21.1 have the middle layer so far. The other supported versions have placeholder +Only 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1 and 1.20.6 have the middle layer so far. The other supported versions have placeholder loader modules (`scaffold=true`) that compile neither lower layer: just an entry point that logs, against that version's loader and toolchain. Porting a version means adding its `minecraft-` folder, copying the loader glue in and dropping the flag. @@ -78,10 +78,96 @@ fires its event, so the shared code sees the same thing on every loader: | 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` | +| Streamed sounds | `SoundInstance.getStream` up to 1.21.5; `PackSoundBuffers` from 1.21.11 | `SoundInstance.getStream` | mixin adding `getAudioStream` to `RuntimeSoundInstance` | NeoForge rolls the camera the other way round from Forge, so its mixin negates the roll. +Forge 1.21.11 and later still declare `SoundInstance.getStream`, but their `SoundEngine` calls +`SoundBufferLibrary.getStream` directly, and a failed stream leaves no trace in the log. So +`forge-` swaps the library for its own subclass on `SoundEngineLoadEvent`, through an access +transformer on the field. The subclass only steps in for an `mcheli` sound no resource pack has. + +### Minecraft 26.2 + +`minecraft-26.2` is `minecraft-26.3` with these differences: + +- **Rendering API.** Pipelines live in `com.mojang.blaze3d` rather than `com.mojang.renderpearl`, + there are no order independent transparency pipelines to name, and `PoseStack` turns through + `mulPose`. `Glow` spells out vanilla's eyes pipeline, which has no snippet to build on yet, and + `ProjectileRenderer` makes its own translucent culled render type, since vanilla only offers one + drawn into the item target. +- **Interpolation.** `Entity` does not own or run a handler. Each of our entities holds an + `InterpolationHandler`, returns it from `getInterpolation` and calls `interpolate` after + `super.tick()`, the way vanilla's boat does. +- **Thermal view.** There is one post effect slot on `GameRenderer` rather than a list on the player, + and its setter is private, so each loader hands `ClientHooks.init` a way to call it: an access + transformer on Forge, a mixin invoker on NeoForge and Fabric. The shader has no explicit attribute + locations, matching vanilla's on this version. +- **Input on Forge.** `InteractionKeyMappingTriggered` is one event with `isUseItem` and `isPickBlock`, + not an event per key. +- **Rider pose.** `CameraRenderState` does not carry the camera entity's partial tick, so Forge and + Fabric ask `ClientHooks.cameraPartialTicks`. NeoForge's event has it. + +### Minecraft 26.1 + +`minecraft-26.1` is `minecraft-26.2` with these differences: + +- **Depth.** 26.1 is the last version with a normal depth buffer, so the glow pipelines test with + `LESS_THAN_OR_EQUAL`. The enum exists on both sides of the change, so the wrong one compiles and + only shows as glows drawn through walls. +- **Rendering API.** Pipelines take samplers and a vertex format through `withSampler` and + `withVertexFormat` rather than bind groups and a primitive topology. +- **Screens.** They live on `Minecraft`, not `Gui`, as they do on 1.21.1. +- **Teams.** There is no `TeamColor`. The team manager picks from the `ChatFormatting` colours. +- **Block entity types.** The constructor is private in vanilla, so `Platform.blockEntityType` makes + them: Forge and NeoForge have opened the constructor, Fabric API has a builder. +- **Camera hooks.** `GameRenderer.update` also takes whether game time advances, and Fabric's + camera mixin shadows `Vector3f` constants rather than `Vector3fc`. + +### Minecraft 1.21.11 + +`minecraft-1.21.11` is `minecraft-26.1` with these differences. It is obfuscated in production, so +Forge sets the official mappings and Fabric uses the remapping Loom plugin. + +- **Cutout render types.** `entityCutout` culls here and does not on 26.x, and `entityCutoutNoCull` + is what 26.x calls `entityCutout`. The names exist on both sides, so a straight copy compiles and + turns parts inside out. +- **Screens.** `render`, `renderBg` and `renderLabels` rather than the `extract` methods, on + `GuiGraphics` with `drawString` rather than `GuiGraphicsExtractor` with `text`. Container screens + set their size after `super`. +- **Pipelines.** `withBlend` and `withDepthWrite` rather than colour target and depth stencil states. +- **Interaction.** A click on the hull arrives through `interactAt`, as on 1.21.1. +- **Camera.** `GameRenderer.updateCamera` calls `Camera.setup`, and NeoForge and Fabric hook after that + and at the head of `extractCamera`. Field of view is `GameRenderer.getFov`. +- **Pack metadata.** One `pack.mcmeta` is read both as client resources and as server data, and each has + its own limit: resources refuse `supported_formats` above format 64, data requires it up to 81. So a range + can only start at 82, above the game's own resource format of 75. All three loaders still load the mod's + resources, since mod packs are not refused for their format. +- **Fabric API.** The older names: `KeyBindingHelper`, `ParticleFactoryRegistry`, `FabricItemGroup`, + `PayloadTypeRegistry.playS2C`, and on `CustomIngredient` `getMatchingItems` and `toDisplay`. +- **Conventional tags.** Forge on 1.21.x has `forge:gunpowder` and `forge:feathers` but not the `c:` + names our recipes use, so its modules define `c:gunpowders` and `c:feathers` from those. + +### Minecraft 1.21.5 + +`minecraft-1.21.5` takes entities, items, recipes and networking from `minecraft-1.21.11`, and its +screens' input handling and the workshop from `minecraft-1.21.1`. What differs: + +- **Rendering.** There are render states but no submit step. `render` draws straight into a + `MultiBufferSource`, and `Geometry.submit` keeps those calls reading the same as elsewhere. +- **Save data.** `CompoundTag` has `store`, `read` and `getIntOr`, so it stands in for `ValueOutput` + and `ValueInput`. Anything holding items needs the registries passed along. +- **Interpolation.** A packet with only a rotation interpolates to where the entity is now, dropping + the target, so `VehicleInterpolation` keeps the target in that case. +- **Thermal view.** Post effects use plain uniforms: `InSize` rather than a `SamplerInfo` block, and + a `uniforms` list in the JSON. +- **`blit`.** It takes a render type function, then position, texels and size in that order, so a + call from 1.21.1 with its size first still compiles and draws the wrong region. +- **Glow.** Our render types come from a nested subclass, the only place the shards they need can be + seen, and vanilla's `itemEntityTranslucentCull` draws translucent rounds. +- **Hull outline.** The render step has no entity, so the extract step copies the boxes into the state. +- **Teams.** There is no PVP game rule, so the team manager switches the server's setting, as on 1.21.1. + ### Minecraft 1.21.1 `minecraft-1.21.1` is a copy of `minecraft-26.3` ported back, so the two read side by side. During @@ -106,6 +192,39 @@ the betas it is not kept in step; each final release brings it, and every other - **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. +- **Drafting table.** There is no recipe display system. The client holds every recipe, so the + screen reads its `RecipeManager` and `DraftingTablePacket` sends the recipe's id. +- **Thermal view.** Post effects are the older `shaders/post` chains, and vanilla loads programs + from the `minecraft` namespace only, so ours is `assets/minecraft/shaders/program/mcheli_thermal`. + `GameRenderer.loadEffect` is private, so each loader hands `ClientHooks.init` a way to call it. +- **Particles.** Smoke is a custom `ParticleRenderType` too. Vanilla only draws the types on its + own list, so `ParticleEngineMixin` adds ours on Fabric; Forge and NeoForge draw every type in use. +- **Workshop preview.** With no GUI entity renderer, `WorkshopScreen` draws through + `AircraftRenderer.draw` the way vanilla draws a mob in the inventory, then clears the depth + buffer so the gizmos stay on top. +- **Shaders.** There is no `IrisCompat`: Iris's pipeline assignment only exists for 26.x. +- **Teams.** There is no PVP game rule or `TeamColor`. The team manager switches the server's PVP + setting, which lasts until a restart, and picks from the `ChatFormatting` colours. + +### Minecraft 1.20.6 + +`minecraft-1.20.6` is `minecraft-1.21.1` with these differences: + +- **Data folders.** They still have plural names (`recipes`, `loot_tables`, `tags/items`), so + `ImportTarget.V1_20_6` writes recipes to `data/mcheli/recipes`, and the jar guard in + `gradle/loader.gradle` keeps EMB4's out of both spellings. +- **Vertices.** The older builder: `vertex`, `color`, `uv`, `overlayCoords`, `uv2`, `normal`, and an + `endVertex` to finish each one. +- **Particle render types.** `begin` is handed a builder and `end` a tesselator. +- **Camera.** The view matrix comes from the camera's angles rather than its quaternion, and each loader + gets the roll in differently. NeoForge's `ComputeCameraAngles` stacks it on top of the view with the + opposite sign, so its glue negates it and needs no camera mixin. Fabric hooks after + `resetProjectionMatrix` and wraps `Matrix4f.rotationXYZ` to do the same. Forge passes the event's roll + into `rotationXYZ` itself, where it turns around the world's Z axis, so `ForgeClient` replaces all three + angles with `AircraftRotation.rollInXyzOrder`, which builds the same matrix. +- **Conventional tags.** Forge has none of the `c:` tags, so `forge-1.20.6` defines all our recipes use, + from the vanilla items and any matching `forge:` tag. Fabric lacks five, defined in `fabric-1.20.6`. +- **Respawn.** `TeamManager` finds the respawn point itself, since there is no `DimensionTransition`. `client` is only referenced from the loader modules behind a physical client check, so a dedicated server never loads a client class. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ce0636b0..04eedffe 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,7 +4,7 @@ | Tool | Version | Notes | |------|---------|-------| -| JDK | 25 (21 for 1.20.6 to 1.21.11, 17 for 1.19.4 and 1.20.2) | Gradle provisions them through the Foojay resolver if none is installed. | +| JDK | 25 (21 for 1.20.6 to 1.21.11, 17 for the placeholders on 1.19.4 and 1.20.2) | 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. | @@ -12,9 +12,24 @@ | 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 | 65.1.5 for Minecraft 26.2 | | +| NeoForge | 26.2.0.93 for Minecraft 26.2 | | +| Fabric | Loader 0.20.1, Fabric API 0.161.0+26.2 | | +| Forge | 62.0.9 for Minecraft 26.1 | | +| NeoForge | 26.1.2.126 for Minecraft 26.1 | | +| Fabric | Loader 0.20.1, Fabric API 0.145.1+26.1 | | +| Forge | 61.2.4 for Minecraft 1.21.11 | Built with ForgeGradle 7 and the official mappings. | +| NeoForge | 21.11.48 for Minecraft 1.21.11 | | +| Fabric | Fabric API 0.141.6+1.21.11 | Loom remaps the jar, which is obfuscated on 1.21.11. | +| Forge | 55.1.18 for Minecraft 1.21.5 | Built with ForgeGradle 7 and the official mappings. | +| NeoForge | 21.5.101 for Minecraft 1.21.5 | | +| Fabric | Fabric API 0.128.2+1.21.5 | Loom remaps the jar, which is obfuscated on 1.21.5. | | 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. | +| Forge | 50.2.14 for Minecraft 1.20.6 | Built with ForgeGradle 7 and the official mappings. | +| NeoForge | 20.6.144 for Minecraft 1.20.6 | | +| Fabric | Fabric API 0.100.8+1.20.6 | Loom remaps the jar, which is obfuscated on 1.20.6. | All three use Mojang's official names, so the shared code reads the same on every loader. The loader versions of the [placeholder modules](#placeholder-modules) are in their own @@ -40,8 +55,9 @@ wait for the other process to finish and build again. Shared properties live in the root `gradle.properties`: `mod_version`, the mod metadata, the list 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. During the -betas, `platforms` lists only `forge-26.3`, so CI and the release build one jar; the other modules -stay in the repository and come back to the list for each final release (roadmap phase 13). +betas up to beta.8, `platforms` listed only `forge-26.3`. Roadmap phase 13 adds modules back as each +one catches up, currently the three each on 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1 and 1.20.6; the others stay in the repository +until they are ported. `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`. @@ -59,8 +75,18 @@ minecraft-26.3/ code shared by every loader on Minecraft 26.3 (no 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-26.2/ the same shared code on Minecraft 26.2 +forge-26.2/, neoforge-26.2/, fabric-26.2/ the loader glue for 26.2 +minecraft-26.1/ the same shared code on Minecraft 26.1 +forge-26.1/, neoforge-26.1/, fabric-26.1/ the loader glue for 26.1 +minecraft-1.21.11/ the same shared code on Minecraft 1.21.11 +forge-1.21.11/, neoforge-1.21.11/, fabric-1.21.11/ the loader glue for 1.21.11 +minecraft-1.21.5/ the same shared code on Minecraft 1.21.5 +forge-1.21.5/, neoforge-1.21.5/, fabric-1.21.5/ the loader glue for 1.21.5 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 +minecraft-1.20.6/ the same shared code on Minecraft 1.20.6 +forge-1.20.6/, neoforge-1.20.6/, fabric-1.20.6/ the loader glue for 1.20.6 -/ placeholders for the other supported versions (see below) gradle/loader.gradle what every loader module shares docs/ documentation and content format guides @@ -93,8 +119,9 @@ so each loader and version ships as one jar and runs as one module in developmen 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. +folder `./run` is shared by every 26.3 module, and every other version has its own, such as +`./run-26.1` and `./run-1.21.1`; worlds made by one loader open on the others, but never open a world +on an older Minecraft version than the one that last saved it. 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 @@ -103,9 +130,8 @@ of them on the command line. After pulling the split, re-import the Gradle proje ### Placeholder modules -Every [supported version](ROADMAP.md#supported-versions) has a loader module, but only 26.3 and -1.21.1 are ported. The other 19 (`forge-`, `neoforge-` and `fabric-` for 26.2, 26.1, 1.21.11, -1.21.5 and 1.20.6, plus `forge-` and `fabric-` for 1.20.2 and 1.19.4) are placeholders: each pins +Every [supported version](ROADMAP.md#supported-versions) is ported. Four more modules, `forge-` and +`fabric-` for 1.20.2 and 1.19.4, are on hold and not part of 1.1.0. They are placeholders: each pins its loader, mappings and Java version, and holds only an entry point that logs a line at startup and the mod metadata. Their `gradle.properties` sets `scaffold=true`, which tells `gradle/loader.gradle` to leave out `common` and `minecraft-`, so they build before that @@ -123,8 +149,17 @@ Porting a version for a final release: add `minecraft-` (copied from th version), copy the loader glue from the ported module of the same loader, remove `scaffold=true` and add the module to `platforms`. +A clean compile proves little about a port. Compare what the loaders report as well: a recipe that names an +undefined tag is logged and dropped on one loader while the others load it, and on 1.21.1 and older it +loads with an ingredient nothing matches, so count those too. Mixin targets, the Forge access transformer, shaders and +data files only fail at runtime, so start a client and a dedicated server on every loader of that +version, and check a built jar. A loader can also pin a library more strictly than the version the +root `gradle.properties` asks for; override it in that module's `gradle.properties`, as NeoForge 26.2 +and 26.1 do with `jspecify_version`. A first run in a new run folder stops at vanilla's accessibility +screen instead of the title screen, which matters only to anything that automates a client. + There is no `neoforge-1.20.2`: ModDevGradle needs a "moddev bundle" that NeoForge 20.2 does not -publish, so NeoForge support starts at 1.20.6, the next supported version. +publish, so NeoForge could not join those placeholders. `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) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0e473659..821a9eb7 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-13 | `1.1.0-beta.1` to `beta.9` | Content packs, NeoForge and Fabric, equipment, workshop, every release version from 1.19.4 to 26.3 ([plan](#road-to-110)) | In progress | +| 5-13 | `1.1.0-beta.1` to `beta.9` | Content packs, NeoForge and Fabric, equipment, workshop, every release version from 1.20.6 to 26.3 ([plan](#road-to-110)) | In progress | | | `1.1.0` | Release on CurseForge | Planned | ## Phase 1: foundation and flyable helicopters @@ -140,7 +140,7 @@ Rules that hold for every beta: | 11 | `1.1.0-beta.7` | Workshop: model, parts and tuning (Forge 26.3 only) | Released | | 12 | `1.1.0-beta.8` | Workshop: textures, sounds and sharing (Forge 26.3 only) | Released | | | `1.1.0-beta.8.1` | Hotfix: the files missing from the released jars | Released | -| 13 | `1.1.0-beta.9` | Every loader on every supported version, 1.19.4 to 26.3 | Planned | +| 13 | `1.1.0-beta.9` | Every loader on every supported version, 1.20.6 to 26.3 | Released | | | `1.1.0` | Release on CurseForge | Planned | The order is deliberate. The registry and the source split come first because everything else @@ -166,13 +166,12 @@ only. | 1.21.5 | 21 | yes | yes | yes | | 1.21.1 | 21 | yes | yes | yes | | 1.20.6 | 21 | yes | yes | yes | -| 1.20.2 | 17 | yes | no | yes | -| 1.19.4 | 17 | yes | no | yes | -That is 25 jars per final release. NeoForge starts at 1.20.6 (ModDevGradle, which builds the -NeoForge jars, cannot set up NeoForge 1.20.2), and 1.19.4 is the oldest version. -Older versions are not frozen: every final release ships on the whole list again. Every pair -already has a Gradle module; the ones not ported yet are placeholders that only load and log +That is 21 jars per final release. Older versions are not frozen: every final release ships on the +whole list again. + +1.20.2 and 1.19.4 (Forge and Fabric) are on hold and not part of 1.1.0: they need Java 17 and vehicle +items without data components. Their Gradle modules remain as placeholders that only load and log (see [DEVELOPMENT.md](DEVELOPMENT.md#placeholder-modules)). ## Phase 5: groundwork (`1.1.0-beta.1`) @@ -226,8 +225,9 @@ almost everything, adds NeoForge and Fabric on 26.3, then brings all three to 1. - [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) +- Dropped: a Fabric client on a NeoForge server, or the reverse. Tried in phase 13, each loader + refuses the other at its own handshake before the mod runs. A pack zip copied by hand still works + on every loader, since the format knows nothing about them. - [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 @@ -300,26 +300,35 @@ built here syncs to other players with no extra work. Betas 4 to 8 ship only the Forge 26.3 jar, to keep builds fast. This phase brings the features added since beta.3 to NeoForge, Fabric and 1.21.1, and ports the mod to the rest of the -[supported versions](#supported-versions), down to 1.19.4. All 25 jars ship from here on. +[supported versions](#supported-versions), down to 1.20.6. All 21 jars ship from here on. - [x] A Gradle module for every version and loader pair, the missing ones as placeholders that download their loader and only load and log -- [ ] Every module in `platforms`; phases 8 to 12 ported to NeoForge and Fabric on 26.3 and to all - three loaders on 1.21.1 -- [ ] `minecraft-26.2`, `minecraft-26.1`, `minecraft-1.21.11`, `minecraft-1.21.5`, - `minecraft-1.20.6`, `minecraft-1.20.2` and `minecraft-1.19.4`, each replacing its - placeholder modules with real glue -- [ ] `common` compiles for Java 17 (no pattern matching `switch`, no `SequencedCollection` - methods) -- [ ] Vehicle items keep their vehicle in NBT on 1.20.2 and 1.19.4, since data components only - arrived in 1.20.5 -- [ ] Legacy importer and content packs checked on every version -- [ ] The phase 7 smoke tests for every jar +- [x] Phases 8 to 12 ported to NeoForge and Fabric on 26.3 +- [x] Every resource the repository holds ships in the jar (betas 4 to 8 lacked recipes, the thermal + shader, the smoke particle and the drafting table model) +- [x] Phases 8 to 12 ported to all three loaders on 1.21.1 +- [x] Every supported version's modules in `platforms` +- [x] `minecraft-26.2` with its Forge, NeoForge and Fabric glue +- [x] `minecraft-26.1` with its Forge, NeoForge and Fabric glue +- [x] `minecraft-1.21.11` with its Forge, NeoForge and Fabric glue +- [x] `minecraft-1.21.5` with its Forge, NeoForge and Fabric glue +- [x] `minecraft-1.20.6` with its Forge, NeoForge and Fabric glue +- [x] Legacy importer and content packs checked on every version: a scripted client on each of the 21 + modules loads the imported recipes and the example pack, spawns its vehicle and plays a pack + sound from the server. Server sync of packs moved to the smoke tests below +- [x] Pack sounds play on every Forge version (silent on 1.21.11 and later before this phase) +- [x] The camera roll on 1.20.6 follows the hull on all three loaders + +Scripted clients found the bugs above, but nobody has played these jars, so the phase 7 smoke tests moved +to the 1.1.0 release. Only the Forge 26.3 jar has been started from a launcher profile. ## 1.1.0 release No new features after beta.9; a `1.1.0-rc.N` follows only if beta.9 testing finds problems. +- [ ] The phase 7 smoke tests for every jar ([TESTING.md](TESTING.md#smoke-test-every-jar)), each from a + launcher profile, with a real flight, weapons, sound by ear and a pack download from a server - [ ] 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 - [ ] A pack made in the workshop on one version loads on every other supported version diff --git a/docs/TESTING.md b/docs/TESTING.md index 5c3e6d88..cdc2f174 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -31,7 +31,8 @@ Run this for each jar in `build/release`: Forge, NeoForge and Fabric on every Mi - [ ] 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). +- [ ] Engine and rotor sounds play. Also a content pack sound: the example pack has none, so give a + workshop vehicle an OGG for its engine or a weapon and listen to it. - [ ] `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 @@ -44,9 +45,53 @@ Run this for each jar in `build/release`: Forge, NeoForge and Fabric on every Mi - [ ] 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 client on the same loader as the dedicated server joins and downloads its packs. A client on + another loader is refused by the loaders themselves, with a message naming the one it needs. - [ ] A `serverconfig/mcheli-server.toml` written by 1.1.0-beta.2 (Forge) keeps its values. +### 1.20.6 only + +- [ ] From the built jars on a launcher profile: all three loaders start, and thermal turns on and off. +- [ ] In a flight with real turns, banked right, the cockpit stays upright on screen and the horizon's right + side rises, on every heading and all three loaders. Held at a fixed bank in a driver this already + matches 1.21.1 (see `AircraftRotation.rollInXyzOrder`); nobody has flown it. +- [ ] Sight zoom works on all three loaders. +- [ ] A vehicle's recipe crafts on each loader, including one with gunpowder or feathers. +- [ ] "Respawn all" in the team manager sends a player to their bed, and to world spawn without one. + +### 1.21.5 only + +- [ ] From the built jars on a launcher profile: all three loaders start, and thermal turns on and off. +- [ ] A vehicle that turns on the spot stays where the server has it, seen from a second client. +- [ ] Inventory, cargo and a UAV station's item survive a world reload. +- [ ] F3+B outlines the hull boxes next to vanilla's box, and they follow a moving vehicle. +- [ ] The vehicle screen, drafting table and workshop textures sit where they should. +- [ ] The team manager's PVP button works, and PVP goes back to `server.properties` after a restart. + +### 1.21.11 only + +- [ ] From the built jars on a launcher profile: all three loaders start, which proves Fabric's remapped + jar and its mixins, and thermal turns on and off. +- [ ] Vehicle parts are not inside out, from outside and from a seat. +- [ ] Sight zoom works on all three loaders, and rider pose and camera roll look right in a banked aircraft. +- [ ] A recipe with gunpowder or feathers crafts on Forge, here and on 1.21.1. + +### 26.1 only + +- [ ] From the built jars on a launcher profile: thermal on and off on all three loaders, and the + drafting table places and draws on Fabric (its block entity type comes from Fabric API). +- [ ] Tracers and flares are hidden by a block between them and the camera, and show in front of one. +- [ ] Rider pose and the camera roll look right in a banked aircraft on all three loaders. +- [ ] The team manager lists the chat colours, and a new team takes the one picked. + +### 26.2 only + +- [ ] From the built jars on a launcher profile, not a dev run: the thermal view turns on and off on + Forge (its setter is opened by an access transformer, which a dev run applies differently), + NeoForge and Fabric. +- [ ] Rider pose and the camera roll look right in a banked aircraft on all three loaders. +- [ ] With Sodium and Iris for 26.2, tracers, flares and smoke look right (or note how they look). + ### 1.21.1 only - [ ] Vehicle icons show in the creative tabs and the hand (the kind model picks them by @@ -55,6 +100,10 @@ Run this for each jar in `build/release`: Forge, NeoForge and Fabric on every Mi 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. +- [ ] Smoke grenades show their own icons, in the hand and in the drafting table's result slot. +- [ ] The workshop's model turns, zooms and pans with its gizmos staying on the right parts. +- [ ] The team manager's PVP button works, and PVP goes back to `server.properties` after a restart. +- [ ] With Sodium and Iris for 1.21.1, tracers, flares and smoke look right (or note how they look). ## Phase 1 checklist diff --git a/fabric-1.20.6/build.gradle b/fabric-1.20.6/build.gradle index 35a0c27b..3b06b6c2 100644 --- a/fabric-1.20.6/build.gradle +++ b/fabric-1.20.6/build.gradle @@ -7,10 +7,15 @@ plugins { 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' @@ -20,7 +25,7 @@ loom { dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" - // 1.20.6 is obfuscated; Mojang's names keep the code the same as on Forge and NeoForge. + // 1.20.6 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.20.6/gradle.properties b/fabric-1.20.6/gradle.properties index 7e86c3cb..6ec4bb9d 100644 --- a/fabric-1.20.6/gradle.properties +++ b/fabric-1.20.6/gradle.properties @@ -1,4 +1,4 @@ -# Minecraft 1.20.6 on Fabric (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.20.6 on Fabric loader=fabric minecraft_version=1.20.6 minecraft_version_range=~1.20.6 @@ -6,4 +6,3 @@ fabric_loader_version=0.20.1 loader_version_range=>=0.16.0 fabric_api_version=0.100.8+1.20.6 java_version=21 -scaffold=true diff --git a/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 00000000..a7114f13 --- /dev/null +++ b/fabric-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 00000000..5ad9f821 --- /dev/null +++ b/fabric-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java index f30386b9..16bc7b1d 100644 --- a/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -1,19 +1,74 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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; /** - * Placeholder entry point for Minecraft 1.20.6. It only proves that the module builds and loads; - * the Fabric glue from fabric-26.3 replaces it when this version is ported for a final release. + * 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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); - @Override public void onInitialize() { - LOGGER.info("MCHeli Rework placeholder loaded on Fabric for Minecraft 1.20.6; no content yet"); + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + CommonHooks.commonSetup(); + + 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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 00000000..b012a4bb --- /dev/null +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,77 @@ +package com.jnhprojects.mcheli.fabric; + +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.fabric.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$loadEffect(effect)); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); + ParticleFactoryRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleFactoryRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.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/RollableCamera.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java similarity index 53% rename from fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java rename to fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java index f31feaeb..ff30f0ff 100644 --- a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -1,6 +1,6 @@ -package com.jnhprojects.mcheli.fabric.mixin; +package com.jnhprojects.mcheli.fabric; -/** Implemented on {@code Camera} by {@link CameraMixin}. */ +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ 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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 00000000..67e65ec3 --- /dev/null +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,21 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.fabric.RollableCamera; +import net.minecraft.client.Camera; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +/** + * Lets the camera hook reach {@code Camera#setRotation}. The view matrix on 1.20.6 is built from the + * camera's angles alone, so the roll is not for this class: {@code GameRendererMixin} applies it. + */ +@Mixin(Camera.class) +abstract class CameraMixin implements RollableCamera { + @Shadow + protected abstract void setRotation(float yRot, float xRot); + + @Override + public void mcheli$setRotation(float yRot, float xRot, float roll) { + setRotation(yRot, xRot); + } +} diff --git a/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..8e1f0568 --- /dev/null +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#loadEffect} private on 1.20.6. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("loadEffect") + void mcheli$loadEffect(ResourceLocation effect); +} diff --git a/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 00000000..ac25dab7 --- /dev/null +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,68 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.util.Mth; +import org.joml.Matrix4f; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * The camera hook, where Forge fires its camera angle event on 1.20.6 (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; + + @Unique + private float mcheli$viewRoll; + + @Inject(method = "renderLevel", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/GameRenderer;resetProjectionMatrix(Lorg/joml/Matrix4f;)V", shift = At.Shift.AFTER)) + private void mcheli$beforeViewRotation(CallbackInfo ci) { + mcheli$viewRoll = 0.0F; + ClientHooks.computeCameraAngles(mainCamera, mainCamera.getPartialTickTime(), (camera, yaw, pitch, roll) -> { + setRotation(camera, yaw, pitch, roll); + mcheli$viewRoll = roll; + }); + } + + /** + * Vanilla's view matrix has no roll; it stacks on top of the other two, as NeoForge does it. This + * is the inverse of the camera's rotation, hence the negated roll. + */ + @Redirect(method = "renderLevel", at = @At(value = "INVOKE", target = "Lorg/joml/Matrix4f;rotationXYZ(FFF)Lorg/joml/Matrix4f;")) + private Matrix4f mcheli$withRoll(Matrix4f matrix, float x, float y, float z) { + return new Matrix4f().rotationZ(-mcheli$viewRoll * Mth.DEG_TO_RAD).mul(matrix.rotationXYZ(x, y, z)); + } + + @Inject(method = "renderItemInHand", at = @At("HEAD"), cancellable = true) + private void mcheli$renderItemInHand(CallbackInfo ci) { + if (ClientHooks.hidesHand()) { + ci.cancel(); + } + } + + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "getFov", at = @At("RETURN"), cancellable = true) + private void mcheli$getFov(Camera camera, float partialTicks, boolean useFovSetting, CallbackInfoReturnable cir) { + cir.setReturnValue((double) ClientHooks.fov((float) cir.getReturnValueD())); + } + + private static void setRotation(Camera camera, float yaw, float pitch, float roll) { + ((RollableCamera) camera).mcheli$setRotation(yaw, pitch, roll); + } +} diff --git a/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 00000000..2cd967b3 --- /dev/null +++ b/fabric-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 00000000..b4685f81 --- /dev/null +++ b/fabric-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 00000000..965719b9 --- /dev/null +++ b/fabric-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java new file mode 100644 index 00000000..87ebfeb7 --- /dev/null +++ b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java @@ -0,0 +1,32 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.google.common.collect.ImmutableList; +import com.jnhprojects.mcheli.client.render.Glow; +import net.minecraft.client.particle.ParticleEngine; +import net.minecraft.client.particle.ParticleRenderType; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +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; + +import java.util.List; + +/** + * Vanilla only draws the particle render types on its own list, which leaves our glow and smoke + * particles invisible. Forge and NeoForge draw every render type in use. + */ +@Mixin(ParticleEngine.class) +abstract class ParticleEngineMixin { + @Shadow + @Final + @Mutable + private static List RENDER_ORDER; + + @Inject(method = "", at = @At("RETURN")) + private static void mcheli$addRenderTypes(CallbackInfo ci) { + RENDER_ORDER = ImmutableList.builder().addAll(RENDER_ORDER).add(Glow.PARTICLE_LAYER, Glow.SMOKE_LAYER).build(); + } +} diff --git a/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-1.20.6/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 00000000..35f4032b --- /dev/null +++ b/fabric-1.20.6/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.20.6/src/main/resources/data/c/tags/items/feathers.json b/fabric-1.20.6/src/main/resources/data/c/tags/items/feathers.json new file mode 100644 index 00000000..acc0cf54 --- /dev/null +++ b/fabric-1.20.6/src/main/resources/data/c/tags/items/feathers.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:feather" + ] +} diff --git a/fabric-1.20.6/src/main/resources/data/c/tags/items/foods/raw_fish.json b/fabric-1.20.6/src/main/resources/data/c/tags/items/foods/raw_fish.json new file mode 100644 index 00000000..04d42afb --- /dev/null +++ b/fabric-1.20.6/src/main/resources/data/c/tags/items/foods/raw_fish.json @@ -0,0 +1,8 @@ +{ + "values": [ + "minecraft:cod", + "minecraft:salmon", + "minecraft:tropical_fish", + "minecraft:pufferfish" + ] +} diff --git a/fabric-1.20.6/src/main/resources/data/c/tags/items/glass_blocks/colorless.json b/fabric-1.20.6/src/main/resources/data/c/tags/items/glass_blocks/colorless.json new file mode 100644 index 00000000..8aeeecb0 --- /dev/null +++ b/fabric-1.20.6/src/main/resources/data/c/tags/items/glass_blocks/colorless.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:glass" + ] +} diff --git a/fabric-1.20.6/src/main/resources/data/c/tags/items/glass_panes/colorless.json b/fabric-1.20.6/src/main/resources/data/c/tags/items/glass_panes/colorless.json new file mode 100644 index 00000000..6bae2d7f --- /dev/null +++ b/fabric-1.20.6/src/main/resources/data/c/tags/items/glass_panes/colorless.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:glass_pane" + ] +} diff --git a/fabric-1.20.6/src/main/resources/data/c/tags/items/gunpowders.json b/fabric-1.20.6/src/main/resources/data/c/tags/items/gunpowders.json new file mode 100644 index 00000000..d2e7016f --- /dev/null +++ b/fabric-1.20.6/src/main/resources/data/c/tags/items/gunpowders.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:gunpowder" + ] +} diff --git a/fabric-1.20.6/src/main/resources/fabric.mod.json b/fabric-1.20.6/src/main/resources/fabric.mod.json index 1b87b793..8346f4c2 100644 --- a/fabric-1.20.6/src/main/resources/fabric.mod.json +++ b/fabric-1.20.6/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "${mod_id}", "version": "${mod_version}", "name": "${mod_name}", - "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. This build is a placeholder: the port to this Minecraft version is not done yet. 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.", + "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", @@ -14,8 +14,10 @@ "icon": "mcheli_logo.png", "environment": "*", "entrypoints": { - "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"] + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] }, + "mixins": ["mcheli.mixins.json"], "depends": { "fabricloader": "${loader_version_range}", "fabric-api": "*", diff --git a/fabric-1.20.6/src/main/resources/mcheli.mixins.json b/fabric-1.20.6/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..22cab45a --- /dev/null +++ b/fabric-1.20.6/src/main/resources/mcheli.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererInvoker", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "ParticleEngineMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} 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 index 382aaccb..16bc7b1d 100644 --- 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 @@ -34,6 +34,7 @@ public void onInitialize() { } CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); registerPackets(); + CommonHooks.commonSetup(); ServerLifecycleEvents.SERVER_STARTING.register(CommonHooks::serverAboutToStart); ServerLifecycleEvents.SERVER_STOPPED.register(server -> CommonHooks.serverStopped()); 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 index 859952f8..b012a4bb 100644 --- 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 @@ -1,5 +1,10 @@ package com.jnhprojects.mcheli.fabric; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.fabric.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.client.ClientHooks; import com.jnhprojects.mcheli.client.particle.GlowParticle; import com.jnhprojects.mcheli.network.ModNetwork; @@ -32,9 +37,12 @@ public final class McheliFabricClient implements ClientModInitializer { @Override public void onInitializeClient() { - ClientHooks.init(); + ClientHooks.init(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$loadEffect(effect)); ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); ParticleFactoryRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleFactoryRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.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))); diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java similarity index 53% rename from fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java rename to fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java index f31feaeb..ff30f0ff 100644 --- a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RollableCamera.java +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -1,6 +1,6 @@ -package com.jnhprojects.mcheli.fabric.mixin; +package com.jnhprojects.mcheli.fabric; -/** Implemented on {@code Camera} by {@link CameraMixin}. */ +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ 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/CameraMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java index 62bf841c..d16eb926 100644 --- 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 @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.fabric.mixin; +import com.jnhprojects.mcheli.fabric.RollableCamera; import net.minecraft.client.Camera; import org.joml.Quaternionf; import org.joml.Vector3f; diff --git a/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..cba28b44 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#loadEffect} private on 1.21.1. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("loadEffect") + void mcheli$loadEffect(ResourceLocation effect); +} 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 index faba98b2..89dafab3 100644 --- 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 @@ -1,6 +1,8 @@ package com.jnhprojects.mcheli.fabric.mixin; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; import net.minecraft.client.Camera; import net.minecraft.client.renderer.GameRenderer; import org.spongepowered.asm.mixin.Final; @@ -34,6 +36,12 @@ abstract class GameRendererMixin { } } + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "getFov", at = @At("RETURN"), cancellable = true) + private void mcheli$getFov(Camera camera, float partialTicks, boolean useFovSetting, CallbackInfoReturnable cir) { + cir.setReturnValue((double) ClientHooks.fov((float) cir.getReturnValueD())); + } + 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/ParticleEngineMixin.java b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java new file mode 100644 index 00000000..87ebfeb7 --- /dev/null +++ b/fabric-1.21.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java @@ -0,0 +1,32 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.google.common.collect.ImmutableList; +import com.jnhprojects.mcheli.client.render.Glow; +import net.minecraft.client.particle.ParticleEngine; +import net.minecraft.client.particle.ParticleRenderType; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +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; + +import java.util.List; + +/** + * Vanilla only draws the particle render types on its own list, which leaves our glow and smoke + * particles invisible. Forge and NeoForge draw every render type in use. + */ +@Mixin(ParticleEngine.class) +abstract class ParticleEngineMixin { + @Shadow + @Final + @Mutable + private static List RENDER_ORDER; + + @Inject(method = "", at = @At("RETURN")) + private static void mcheli$addRenderTypes(CallbackInfo ci) { + RENDER_ORDER = ImmutableList.builder().addAll(RENDER_ORDER).add(Glow.PARTICLE_LAYER, Glow.SMOKE_LAYER).build(); + } +} diff --git a/fabric-1.21.1/src/main/resources/mcheli.mixins.json b/fabric-1.21.1/src/main/resources/mcheli.mixins.json index 1b14b27a..22cab45a 100644 --- a/fabric-1.21.1/src/main/resources/mcheli.mixins.json +++ b/fabric-1.21.1/src/main/resources/mcheli.mixins.json @@ -8,9 +8,11 @@ ], "client": [ "CameraMixin", + "GameRendererInvoker", "GameRendererMixin", "LivingEntityRendererMixin", "MinecraftMixin", + "ParticleEngineMixin", "RuntimeSoundInstanceMixin" ], "injectors": { diff --git a/fabric-1.21.11/build.gradle b/fabric-1.21.11/build.gradle index cbaa0761..a4111c63 100644 --- a/fabric-1.21.11/build.gradle +++ b/fabric-1.21.11/build.gradle @@ -7,10 +7,15 @@ plugins { 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' @@ -20,7 +25,7 @@ loom { dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" - // 1.21.11 is obfuscated; Mojang's names keep the code the same as on Forge and NeoForge. + // 1.21.11 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.11/gradle.properties b/fabric-1.21.11/gradle.properties index 860d7a50..3daa5f28 100644 --- a/fabric-1.21.11/gradle.properties +++ b/fabric-1.21.11/gradle.properties @@ -1,4 +1,4 @@ -# Minecraft 1.21.11 on Fabric (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.21.11 on Fabric loader=fabric minecraft_version=1.21.11 minecraft_version_range=~1.21.11 @@ -6,4 +6,3 @@ fabric_loader_version=0.20.1 loader_version_range=>=0.16.0 fabric_api_version=0.141.6+1.21.11 java_version=21 -scaffold=true diff --git a/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 00000000..707dd713 --- /dev/null +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java @@ -0,0 +1,71 @@ +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.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import java.nio.file.Path; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return FabricBlockEntityTypeBuilder.create(factory::apply, block).build(); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return FabricItemGroup.builder(); + } +} diff --git a/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 00000000..eeeba6f3 --- /dev/null +++ b/fabric-1.21.11/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 getPacketCodec() { + return streamCodec; + } + }; + + @Override + public boolean test(ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public Stream> getMatchingItems() { + return VehicleIngredient.items(); + } + + @Override + public boolean requiresTesting() { + return true; + } + + @Override + public CustomIngredientSerializer getSerializer() { + return SERIALIZER; + } + + @Override + public SlotDisplay toDisplay() { + return VehicleIngredient.display(vehicle); + } +} diff --git a/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java index 7c4837c1..9f9e2331 100644 --- a/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -1,19 +1,74 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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; /** - * Placeholder entry point for Minecraft 1.21.11. It only proves that the module builds and loads; - * the Fabric glue from fabric-26.3 replaces it when this version is ported for a final release. + * 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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); - @Override public void onInitialize() { - LOGGER.info("MCHeli Rework placeholder loaded on Fabric for Minecraft 1.21.11; no content yet"); + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + CommonHooks.commonSetup(); + + 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.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.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 00000000..8fd926e0 --- /dev/null +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,56 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.fabric.mixin.GameRendererInvoker; +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.hud.HudElementRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); + ParticleFactoryRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleFactoryRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.Provider::new); + ClientHooks.keyMappings().forEach(KeyBindingHelper::registerKeyBinding); + ClientHooks.reloadListeners().forEach(ResourceLoader.get(PackType.CLIENT_RESOURCES)::registerReloader); + 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-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java new file mode 100644 index 00000000..ff30f0ff --- /dev/null +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric; + +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ +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.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 00000000..d16eb926 --- /dev/null +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,54 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.fabric.RollableCamera; +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.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..ac63c87b --- /dev/null +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 1.21.11. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(Identifier effect); +} diff --git a/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 00000000..84e93b0c --- /dev/null +++ b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,54 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +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; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * 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 = "updateCamera", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;setup(Lnet/minecraft/world/level/Level;Lnet/minecraft/world/entity/Entity;ZZF)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(float worldPartialTicks, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, worldPartialTicks, GameRendererMixin::setRotation); + } + + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "getFov", at = @At("RETURN"), cancellable = true) + private void mcheli$getFov(Camera camera, float partialTicks, boolean useFovSetting, CallbackInfoReturnable cir) { + cir.setReturnValue(ClientHooks.fov(cir.getReturnValueF())); + } + + @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.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 00000000..4539dd43 --- /dev/null +++ b/fabric-1.21.11/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.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/CameraRenderState;)V", + at = @At("HEAD")) + private void mcheli$beforeSubmit(LivingEntityRenderState state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera, + CallbackInfo ci) { + ClientHooks.beforeRenderLiving(state, pose, ClientHooks.cameraPartialTicks()); + } +} diff --git a/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 00000000..b4685f81 --- /dev/null +++ b/fabric-1.21.11/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.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 00000000..965719b9 --- /dev/null +++ b/fabric-1.21.11/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.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-1.21.11/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 00000000..f085c684 --- /dev/null +++ b/fabric-1.21.11/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-1.21.11/src/main/resources/fabric.mod.json b/fabric-1.21.11/src/main/resources/fabric.mod.json index 1b87b793..8346f4c2 100644 --- a/fabric-1.21.11/src/main/resources/fabric.mod.json +++ b/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "${mod_id}", "version": "${mod_version}", "name": "${mod_name}", - "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. This build is a placeholder: the port to this Minecraft version is not done yet. 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.", + "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", @@ -14,8 +14,10 @@ "icon": "mcheli_logo.png", "environment": "*", "entrypoints": { - "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"] + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] }, + "mixins": ["mcheli.mixins.json"], "depends": { "fabricloader": "${loader_version_range}", "fabric-api": "*", diff --git a/fabric-1.21.11/src/main/resources/mcheli.mixins.json b/fabric-1.21.11/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..3dcbaf14 --- /dev/null +++ b/fabric-1.21.11/src/main/resources/mcheli.mixins.json @@ -0,0 +1,20 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererInvoker", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/fabric-1.21.5/build.gradle b/fabric-1.21.5/build.gradle index 2cb25151..18ea8484 100644 --- a/fabric-1.21.5/build.gradle +++ b/fabric-1.21.5/build.gradle @@ -7,10 +7,15 @@ plugins { 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' @@ -20,7 +25,7 @@ loom { dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" - // 1.21.5 is obfuscated; Mojang's names keep the code the same as on Forge and NeoForge. + // 1.21.5 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.5/gradle.properties b/fabric-1.21.5/gradle.properties index 987d2f24..d12bc0d5 100644 --- a/fabric-1.21.5/gradle.properties +++ b/fabric-1.21.5/gradle.properties @@ -1,4 +1,4 @@ -# Minecraft 1.21.5 on Fabric (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.21.5 on Fabric loader=fabric minecraft_version=1.21.5 minecraft_version_range=~1.21.5 @@ -6,4 +6,3 @@ fabric_loader_version=0.20.1 loader_version_range=>=0.16.0 fabric_api_version=0.128.2+1.21.5 java_version=21 -scaffold=true diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 00000000..707dd713 --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java @@ -0,0 +1,71 @@ +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.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import java.nio.file.Path; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return FabricBlockEntityTypeBuilder.create(factory::apply, block).build(); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return FabricItemGroup.builder(); + } +} diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 00000000..5267068a --- /dev/null +++ b/fabric-1.21.5/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.ResourceLocation; +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 ResourceLocation getIdentifier() { + return VehicleIngredient.ID; + } + + @Override + public MapCodec getCodec() { + return codec; + } + + @Override + public StreamCodec getPacketCodec() { + return streamCodec; + } + }; + + @Override + public boolean test(ItemStack stack) { + return VehicleIngredient.matches(vehicle, stack); + } + + @Override + public Stream> getMatchingItems() { + return VehicleIngredient.items(); + } + + @Override + public boolean requiresTesting() { + return true; + } + + @Override + public CustomIngredientSerializer getSerializer() { + return SERIALIZER; + } + + @Override + public SlotDisplay toDisplay() { + return VehicleIngredient.display(vehicle); + } +} diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java index d3f6badd..31233345 100644 --- a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -1,19 +1,74 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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; /** - * Placeholder entry point for Minecraft 1.21.5. It only proves that the module builds and loads; - * the Fabric glue from fabric-26.3 replaces it when this version is ported for a final release. + * 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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); - @Override public void onInitialize() { - LOGGER.info("MCHeli Rework placeholder loaded on Fabric for Minecraft 1.21.5; no content yet"); + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + CommonHooks.commonSetup(); + + 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.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.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 00000000..435a1dbf --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.fabric.mixin.GameRendererInvoker; +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.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; +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 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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); + ParticleFactoryRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleFactoryRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.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, Executor background, Executor main) { + return listener.reload(barrier, manager, background, main); + } + } +} diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java new file mode 100644 index 00000000..ff30f0ff --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric; + +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ +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.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 00000000..d16eb926 --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,54 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.fabric.RollableCamera; +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.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..758f2ba0 --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 1.21.5. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(ResourceLocation effect); +} diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 00000000..a74fad9c --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +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.5 (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(); + } + } + + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "getFov", at = @At("RETURN"), cancellable = true) + private void mcheli$getFov(Camera camera, float partialTicks, boolean useFovSetting, CallbackInfoReturnable cir) { + cir.setReturnValue(ClientHooks.fov(cir.getReturnValueF())); + } + + 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.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 00000000..e0897e6a --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java @@ -0,0 +1,22 @@ +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.client.renderer.entity.state.LivingEntityRenderState; +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/client/renderer/entity/state/LivingEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;" + + "Lnet/minecraft/client/renderer/MultiBufferSource;I)V", at = @At("HEAD")) + private void mcheli$beforeRender(LivingEntityRenderState state, PoseStack pose, MultiBufferSource buffers, int light, CallbackInfo ci) { + ClientHooks.beforeRenderLiving(state, pose, ClientHooks.cameraPartialTicks()); + } +} diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 00000000..b4685f81 --- /dev/null +++ b/fabric-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 00000000..965719b9 --- /dev/null +++ b/fabric-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java new file mode 100644 index 00000000..87ebfeb7 --- /dev/null +++ b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/ParticleEngineMixin.java @@ -0,0 +1,32 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.google.common.collect.ImmutableList; +import com.jnhprojects.mcheli.client.render.Glow; +import net.minecraft.client.particle.ParticleEngine; +import net.minecraft.client.particle.ParticleRenderType; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +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; + +import java.util.List; + +/** + * Vanilla only draws the particle render types on its own list, which leaves our glow and smoke + * particles invisible. Forge and NeoForge draw every render type in use. + */ +@Mixin(ParticleEngine.class) +abstract class ParticleEngineMixin { + @Shadow + @Final + @Mutable + private static List RENDER_ORDER; + + @Inject(method = "", at = @At("RETURN")) + private static void mcheli$addRenderTypes(CallbackInfo ci) { + RENDER_ORDER = ImmutableList.builder().addAll(RENDER_ORDER).add(Glow.PARTICLE_LAYER, Glow.SMOKE_LAYER).build(); + } +} diff --git a/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-1.21.5/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 00000000..35f4032b --- /dev/null +++ b/fabric-1.21.5/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.5/src/main/resources/fabric.mod.json b/fabric-1.21.5/src/main/resources/fabric.mod.json index 1b87b793..8346f4c2 100644 --- a/fabric-1.21.5/src/main/resources/fabric.mod.json +++ b/fabric-1.21.5/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "${mod_id}", "version": "${mod_version}", "name": "${mod_name}", - "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. This build is a placeholder: the port to this Minecraft version is not done yet. 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.", + "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", @@ -14,8 +14,10 @@ "icon": "mcheli_logo.png", "environment": "*", "entrypoints": { - "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"] + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] }, + "mixins": ["mcheli.mixins.json"], "depends": { "fabricloader": "${loader_version_range}", "fabric-api": "*", diff --git a/fabric-1.21.5/src/main/resources/mcheli.mixins.json b/fabric-1.21.5/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..22cab45a --- /dev/null +++ b/fabric-1.21.5/src/main/resources/mcheli.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererInvoker", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "ParticleEngineMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/fabric-26.1/build.gradle b/fabric-26.1/build.gradle index dcc4eded..c4cfd3fb 100644 --- a/fabric-26.1/build.gradle +++ b/fabric-26.1/build.gradle @@ -7,10 +7,15 @@ plugins { 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' diff --git a/fabric-26.1/gradle.properties b/fabric-26.1/gradle.properties index 037b1476..f35e4967 100644 --- a/fabric-26.1/gradle.properties +++ b/fabric-26.1/gradle.properties @@ -1,4 +1,4 @@ -# Minecraft 26.1 on Fabric (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 26.1 on Fabric loader=fabric minecraft_version=26.1 minecraft_version_range=~26.1 @@ -6,4 +6,3 @@ fabric_loader_version=0.20.1 loader_version_range=>=0.20.0 fabric_api_version=0.145.1+26.1 java_version=25 -scaffold=true diff --git a/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 00000000..58861da2 --- /dev/null +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java @@ -0,0 +1,71 @@ +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.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import java.nio.file.Path; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return FabricBlockEntityTypeBuilder.create(factory::apply, block).build(); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return FabricCreativeModeTab.builder(); + } +} diff --git a/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 00000000..ecc8b270 --- /dev/null +++ b/fabric-26.1/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.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java index 4547c6a5..d7503501 100644 --- a/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -1,19 +1,74 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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; /** - * Placeholder entry point for Minecraft 26.1. It only proves that the module builds and loads; - * the Fabric glue from fabric-26.3 replaces it when this version is ported for a final release. + * 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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); - @Override public void onInitialize() { - LOGGER.info("MCHeli Rework placeholder loaded on Fabric for Minecraft 26.1; no content yet"); + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + CommonHooks.commonSetup(); + + 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.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 00000000..edaeb0b4 --- /dev/null +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,56 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.fabric.mixin.GameRendererInvoker; +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.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); + ParticleProviderRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleProviderRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.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.1/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java new file mode 100644 index 00000000..ff30f0ff --- /dev/null +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric; + +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ +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.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 00000000..1e8a57a7 --- /dev/null +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,67 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +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; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * 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; + @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; + } + + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "calculateFov", at = @At("RETURN"), cancellable = true) + private void mcheli$calculateFov(float partialTicks, CallbackInfoReturnable cir) { + cir.setReturnValue(ClientHooks.fov(cir.getReturnValueF())); + } +} diff --git a/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..b7afee17 --- /dev/null +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 26.1. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(Identifier effect); +} diff --git a/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 00000000..ac10d9b0 --- /dev/null +++ b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +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, boolean advanceGameTime, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, deltaTracker.getGameTimeDeltaPartialTick(true), GameRendererMixin::setRotation); + } + + @Inject(method = "extractCamera", at = @At("HEAD")) + private void mcheli$beforeExtractCamera(DeltaTracker deltaTracker, float worldPartialTicks, float cameraEntityPartialTicks, 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.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 00000000..524f5481 --- /dev/null +++ b/fabric-26.1/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, ClientHooks.cameraPartialTicks()); + } +} diff --git a/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 00000000..b155db7c --- /dev/null +++ b/fabric-26.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 = "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.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 00000000..965719b9 --- /dev/null +++ b/fabric-26.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-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-26.1/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 00000000..f085c684 --- /dev/null +++ b/fabric-26.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.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.1/src/main/resources/fabric.mod.json b/fabric-26.1/src/main/resources/fabric.mod.json index 1b87b793..8346f4c2 100644 --- a/fabric-26.1/src/main/resources/fabric.mod.json +++ b/fabric-26.1/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "${mod_id}", "version": "${mod_version}", "name": "${mod_name}", - "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. This build is a placeholder: the port to this Minecraft version is not done yet. 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.", + "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", @@ -14,8 +14,10 @@ "icon": "mcheli_logo.png", "environment": "*", "entrypoints": { - "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"] + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] }, + "mixins": ["mcheli.mixins.json"], "depends": { "fabricloader": "${loader_version_range}", "fabric-api": "*", diff --git a/fabric-26.1/src/main/resources/mcheli.mixins.json b/fabric-26.1/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..3dcbaf14 --- /dev/null +++ b/fabric-26.1/src/main/resources/mcheli.mixins.json @@ -0,0 +1,20 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererInvoker", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/fabric-26.2/build.gradle b/fabric-26.2/build.gradle index dcc4eded..c4cfd3fb 100644 --- a/fabric-26.2/build.gradle +++ b/fabric-26.2/build.gradle @@ -7,10 +7,15 @@ plugins { 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' diff --git a/fabric-26.2/gradle.properties b/fabric-26.2/gradle.properties index a321987f..a6eb67eb 100644 --- a/fabric-26.2/gradle.properties +++ b/fabric-26.2/gradle.properties @@ -1,4 +1,4 @@ -# Minecraft 26.2 on Fabric (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 26.2 on Fabric loader=fabric minecraft_version=26.2 minecraft_version_range=~26.2 @@ -6,4 +6,3 @@ fabric_loader_version=0.20.1 loader_version_range=>=0.20.0 fabric_api_version=0.161.0+26.2 java_version=25 -scaffold=true diff --git a/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/FabricPlatform.java new file mode 100644 index 00000000..0e59c8dd --- /dev/null +++ b/fabric-26.2/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.2/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/FabricVehicleIngredient.java new file mode 100644 index 00000000..ecc8b270 --- /dev/null +++ b/fabric-26.2/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.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java index 0a969049..d7503501 100644 --- a/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java +++ b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabric.java @@ -1,19 +1,74 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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; /** - * Placeholder entry point for Minecraft 26.2. It only proves that the module builds and loads; - * the Fabric glue from fabric-26.3 replaces it when this version is ported for a final release. + * 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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); - @Override public void onInitialize() { - LOGGER.info("MCHeli Rework placeholder loaded on Fabric for Minecraft 26.2; no content yet"); + Platform.install(new FabricPlatform()); + MCHeli.init(); + + for (ResourceKey> registry : Registration.registries()) { + register(Registration.typed(registry)); + } + CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); + registerPackets(); + CommonHooks.commonSetup(); + + 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.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java new file mode 100644 index 00000000..edaeb0b4 --- /dev/null +++ b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/McheliFabricClient.java @@ -0,0 +1,56 @@ +package com.jnhprojects.mcheli.fabric; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.fabric.mixin.GameRendererInvoker; +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.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); + ParticleProviderRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleProviderRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.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.2/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java new file mode 100644 index 00000000..ff30f0ff --- /dev/null +++ b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric; + +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ +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.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java new file mode 100644 index 00000000..4c6bfafe --- /dev/null +++ b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java @@ -0,0 +1,68 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +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; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * 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; + } + + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "calculateFov", at = @At("RETURN"), cancellable = true) + private void mcheli$calculateFov(float partialTicks, CallbackInfoReturnable cir) { + cir.setReturnValue(ClientHooks.fov(cir.getReturnValueF())); + } +} diff --git a/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..5db23311 --- /dev/null +++ b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 26.2. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(Identifier effect); +} diff --git a/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java new file mode 100644 index 00000000..b04559e3 --- /dev/null +++ b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli.fabric.mixin; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; +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, float cameraEntityPartialTicks, 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.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/LivingEntityRendererMixin.java new file mode 100644 index 00000000..524f5481 --- /dev/null +++ b/fabric-26.2/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, ClientHooks.cameraPartialTicks()); + } +} diff --git a/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/MinecraftMixin.java new file mode 100644 index 00000000..b155db7c --- /dev/null +++ b/fabric-26.2/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 = "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.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/PackRepositoryMixin.java new file mode 100644 index 00000000..965719b9 --- /dev/null +++ b/fabric-26.2/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.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java b/fabric-26.2/src/main/java/com/jnhprojects/mcheli/fabric/mixin/RuntimeSoundInstanceMixin.java new file mode 100644 index 00000000..f085c684 --- /dev/null +++ b/fabric-26.2/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.2/src/main/resources/fabric.mod.json b/fabric-26.2/src/main/resources/fabric.mod.json index 1b87b793..8346f4c2 100644 --- a/fabric-26.2/src/main/resources/fabric.mod.json +++ b/fabric-26.2/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "${mod_id}", "version": "${mod_version}", "name": "${mod_name}", - "description": "Helicopters, planes, tanks and vehicles for modern Minecraft. This build is a placeholder: the port to this Minecraft version is not done yet. 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.", + "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", @@ -14,8 +14,10 @@ "icon": "mcheli_logo.png", "environment": "*", "entrypoints": { - "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"] + "main": ["com.jnhprojects.mcheli.fabric.McheliFabric"], + "client": ["com.jnhprojects.mcheli.fabric.McheliFabricClient"] }, + "mixins": ["mcheli.mixins.json"], "depends": { "fabricloader": "${loader_version_range}", "fabric-api": "*", diff --git a/fabric-26.2/src/main/resources/mcheli.mixins.json b/fabric-26.2/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..3dcbaf14 --- /dev/null +++ b/fabric-26.2/src/main/resources/mcheli.mixins.json @@ -0,0 +1,20 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.fabric.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "PackRepositoryMixin" + ], + "client": [ + "CameraMixin", + "GameRendererInvoker", + "GameRendererMixin", + "LivingEntityRendererMixin", + "MinecraftMixin", + "RuntimeSoundInstanceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} 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 index e0f1f298..d7503501 100644 --- 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 @@ -34,6 +34,7 @@ public void onInitialize() { } CustomIngredientSerializer.register(FabricVehicleIngredient.SERIALIZER); registerPackets(); + CommonHooks.commonSetup(); ServerLifecycleEvents.SERVER_STARTING.register(CommonHooks::serverAboutToStart); ServerLifecycleEvents.SERVER_STOPPED.register(server -> CommonHooks.serverStopped()); 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 index 76ff11ce..dced45fe 100644 --- 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 @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.client.ClientHooks; import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.network.ModNetwork; import com.jnhprojects.mcheli.registry.ModParticles; import net.fabricmc.api.ClientModInitializer; @@ -14,6 +15,8 @@ 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.client.gui.screens.MenuScreens; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.server.packs.PackType; @@ -29,7 +32,10 @@ public final class McheliFabricClient implements ClientModInitializer { public void onInitializeClient() { ClientHooks.init(); ClientHooks.registerEntityRenderers(EntityRendererRegistry::register); + ClientHooks.registerBlockEntityRenderers(BlockEntityRenderers::register); + ClientHooks.registerMenuScreens(MenuScreens::register); ParticleProviderRegistry.getInstance().register(ModParticles.GLOW.get(), GlowParticle.Provider::new); + ParticleProviderRegistry.getInstance().register(ModParticles.SMOKE.get(), SmokeParticle.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); diff --git a/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java new file mode 100644 index 00000000..ff30f0ff --- /dev/null +++ b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/RollableCamera.java @@ -0,0 +1,7 @@ +package com.jnhprojects.mcheli.fabric; + +/** Implemented on {@code Camera} by {@code CameraMixin}. Outside the mixin package, which Mixin refuses to load classes from. */ +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/CameraMixin.java b/fabric-26.3/src/main/java/com/jnhprojects/mcheli/fabric/mixin/CameraMixin.java index 32f7075b..4c6bfafe 100644 --- 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 @@ -1,5 +1,7 @@ package com.jnhprojects.mcheli.fabric.mixin; +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; import net.minecraft.client.Camera; import org.joml.Quaternionf; import org.joml.Vector3f; @@ -7,6 +9,9 @@ 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.CallbackInfoReturnable; /** * Vanilla's camera has no roll. This is {@code Camera#setRotation} with the roll Forge patches in, @@ -54,4 +59,10 @@ abstract class CameraMixin implements RollableCamera { LEFT.rotate(this.rotation, this.left); this.matrixPropertiesDirty |= 3; } + + /** Zoom through a sight, where Forge fires its field of view event. */ + @Inject(method = "calculateFov", at = @At("RETURN"), cancellable = true) + private void mcheli$calculateFov(float partialTicks, CallbackInfoReturnable cir) { + cir.setReturnValue(ClientHooks.fov(cir.getReturnValueF())); + } } 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 index 7f67f18d..ab97facf 100644 --- 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 @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.fabric.mixin; import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.fabric.RollableCamera; import net.minecraft.client.Camera; import net.minecraft.client.DeltaTracker; import net.minecraft.client.renderer.GameRenderer; diff --git a/forge-1.20.6/build.gradle b/forge-1.20.6/build.gradle index 51b53eb9..17131ee9 100644 --- a/forge-1.20.6/build.gradle +++ b/forge-1.20.6/build.gradle @@ -7,12 +7,17 @@ plugins { 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') diff --git a/forge-1.20.6/gradle.properties b/forge-1.20.6/gradle.properties index 33de24a2..079c1d8a 100644 --- a/forge-1.20.6/gradle.properties +++ b/forge-1.20.6/gradle.properties @@ -1,8 +1,7 @@ -# Minecraft 1.20.6 on Forge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.20.6 on Forge loader=forge minecraft_version=1.20.6 minecraft_version_range=[1.20.6,1.20.7) forge_version=50.2.14 loader_version_range=[50,) java_version=21 -scaffold=true diff --git a/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 00000000..e9d93fa5 --- /dev/null +++ b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,139 @@ +package com.jnhprojects.mcheli.forge; + +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.client.CameraAngles; +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.physics.AircraftRotation; +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(effect -> Minecraft.getInstance().gameRenderer.loadEffect(effect)); + modBus.addListener((FMLClientSetupEvent event) -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); + modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener((RegisterParticleProvidersEvent event) -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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)); + stackRoll(event); + }); + forgeBus.addListener((ViewportEvent.ComputeFov event) -> event.setFOV(ClientHooks.fov((float) event.getFOV()))); + 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()); + } + + /** + * Forge puts the event's roll straight into {@code rotationXYZ} on 1.20.6, so it turns around the + * world's Z axis rather than the view's. The camera's look vectors were set before the event and + * stay as they are, so this only changes the view matrix of this frame. + */ + private static void stackRoll(ViewportEvent.ComputeCameraAngles event) { + if (event.getRoll() != 0.0F) { + float[] angles = AircraftRotation.rollInXyzOrder(event.getYaw(), event.getPitch(), event.getRoll()); + event.setYaw(angles[0]); + event.setPitch(angles[1]); + event.setRoll(angles[2]); + } + } + + 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.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 00000000..883dd91d --- /dev/null +++ b/forge-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 00000000..d8b9d11c --- /dev/null +++ b/forge-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 00000000..057d0061 --- /dev/null +++ b/forge-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java index 4225f85c..c878e1b4 100644 --- a/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java +++ b/forge-1.20.6/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -1,19 +1,76 @@ package com.jnhprojects.mcheli.forge; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; -/** - * Placeholder entry point for Minecraft 1.20.6. It only proves that the module builds and loads; - * the Forge glue from forge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) public final class McheliForge { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + public McheliForge(FMLJavaModLoadingContext context) { + IEventBus modBus = context.getModEventBus(); + Platform.install(new ForgePlatform()); + MCHeli.init(); - public McheliForge() { - LOGGER.info("MCHeli Rework placeholder loaded on Forge for Minecraft 1.20.6; no content yet"); + 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((FMLCommonSetupEvent event) -> event.enqueueWork(CommonHooks::commonSetup)); + 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.20.6/src/main/resources/META-INF/mods.toml b/forge-1.20.6/src/main/resources/META-INF/mods.toml index 47ec16fb..1fdc3b48 100644 --- a/forge-1.20.6/src/main/resources/META-INF/mods.toml +++ b/forge-1.20.6/src/main/resources/META-INF/mods.toml @@ -13,7 +13,6 @@ 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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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. diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/buckets/water.json b/forge-1.20.6/src/main/resources/data/c/tags/items/buckets/water.json new file mode 100644 index 00000000..165009c1 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/buckets/water.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:water_bucket" + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/chests/wooden.json b/forge-1.20.6/src/main/resources/data/c/tags/items/chests/wooden.json new file mode 100644 index 00000000..75795e06 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/chests/wooden.json @@ -0,0 +1,7 @@ +{ + "values": [ + "minecraft:chest", + "minecraft:trapped_chest", + {"id": "#forge:chests/wooden", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dusts/redstone.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dusts/redstone.json new file mode 100644 index 00000000..ebac6ec1 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dusts/redstone.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:redstone", + {"id": "#forge:dusts/redstone", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/black.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/black.json new file mode 100644 index 00000000..371f5e88 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/black.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:black_dye", + {"id": "#forge:dyes/black", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/blue.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/blue.json new file mode 100644 index 00000000..708dffc5 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/blue.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:blue_dye", + {"id": "#forge:dyes/blue", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/brown.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/brown.json new file mode 100644 index 00000000..9231d54a --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/brown.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:brown_dye", + {"id": "#forge:dyes/brown", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/cyan.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/cyan.json new file mode 100644 index 00000000..a42059cb --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/cyan.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:cyan_dye", + {"id": "#forge:dyes/cyan", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/gray.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/gray.json new file mode 100644 index 00000000..caa1a665 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/gray.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:gray_dye", + {"id": "#forge:dyes/gray", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/green.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/green.json new file mode 100644 index 00000000..31f63302 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/green.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:green_dye", + {"id": "#forge:dyes/green", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/light_blue.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/light_blue.json new file mode 100644 index 00000000..bd6cdedd --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/light_blue.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:light_blue_dye", + {"id": "#forge:dyes/light_blue", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/light_gray.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/light_gray.json new file mode 100644 index 00000000..8b3b4af9 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/light_gray.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:light_gray_dye", + {"id": "#forge:dyes/light_gray", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/lime.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/lime.json new file mode 100644 index 00000000..7be0a5ad --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/lime.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:lime_dye", + {"id": "#forge:dyes/lime", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/magenta.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/magenta.json new file mode 100644 index 00000000..0cadd40a --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/magenta.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:magenta_dye", + {"id": "#forge:dyes/magenta", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/orange.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/orange.json new file mode 100644 index 00000000..6a9410ca --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/orange.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:orange_dye", + {"id": "#forge:dyes/orange", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/pink.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/pink.json new file mode 100644 index 00000000..a39c8e24 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/pink.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:pink_dye", + {"id": "#forge:dyes/pink", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/purple.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/purple.json new file mode 100644 index 00000000..28e42ec8 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/purple.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:purple_dye", + {"id": "#forge:dyes/purple", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/red.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/red.json new file mode 100644 index 00000000..e273b27b --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/red.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:red_dye", + {"id": "#forge:dyes/red", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/white.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/white.json new file mode 100644 index 00000000..78dc1bd9 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/white.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:white_dye", + {"id": "#forge:dyes/white", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/yellow.json b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/yellow.json new file mode 100644 index 00000000..3ce69377 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/dyes/yellow.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:yellow_dye", + {"id": "#forge:dyes/yellow", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/feathers.json b/forge-1.20.6/src/main/resources/data/c/tags/items/feathers.json new file mode 100644 index 00000000..cd043bdb --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/feathers.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:feather", + {"id": "#forge:feathers", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/foods/raw_fish.json b/forge-1.20.6/src/main/resources/data/c/tags/items/foods/raw_fish.json new file mode 100644 index 00000000..04d42afb --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/foods/raw_fish.json @@ -0,0 +1,8 @@ +{ + "values": [ + "minecraft:cod", + "minecraft:salmon", + "minecraft:tropical_fish", + "minecraft:pufferfish" + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/gems/diamond.json b/forge-1.20.6/src/main/resources/data/c/tags/items/gems/diamond.json new file mode 100644 index 00000000..3904df1c --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/gems/diamond.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:diamond", + {"id": "#forge:gems/diamond", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/glass_blocks/colorless.json b/forge-1.20.6/src/main/resources/data/c/tags/items/glass_blocks/colorless.json new file mode 100644 index 00000000..ed0d10ef --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/glass_blocks/colorless.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:glass", + {"id": "#forge:glass/colorless", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/glass_panes/colorless.json b/forge-1.20.6/src/main/resources/data/c/tags/items/glass_panes/colorless.json new file mode 100644 index 00000000..346ef2a2 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/glass_panes/colorless.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:glass_pane", + {"id": "#forge:glass_panes/colorless", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/gunpowders.json b/forge-1.20.6/src/main/resources/data/c/tags/items/gunpowders.json new file mode 100644 index 00000000..955b2de9 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/gunpowders.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:gunpowder", + {"id": "#forge:gunpowder", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/ingots/gold.json b/forge-1.20.6/src/main/resources/data/c/tags/items/ingots/gold.json new file mode 100644 index 00000000..44db933c --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/ingots/gold.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:gold_ingot", + {"id": "#forge:ingots/gold", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/ingots/iron.json b/forge-1.20.6/src/main/resources/data/c/tags/items/ingots/iron.json new file mode 100644 index 00000000..52bf3cf7 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/ingots/iron.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:iron_ingot", + {"id": "#forge:ingots/iron", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/rods/wooden.json b/forge-1.20.6/src/main/resources/data/c/tags/items/rods/wooden.json new file mode 100644 index 00000000..fa97361e --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/rods/wooden.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:stick", + {"id": "#forge:rods/wooden", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/coal.json b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/coal.json new file mode 100644 index 00000000..5255a483 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/coal.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:coal_block", + {"id": "#forge:storage_blocks/coal", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/diamond.json b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/diamond.json new file mode 100644 index 00000000..c7aac185 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/diamond.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:diamond_block", + {"id": "#forge:storage_blocks/diamond", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/gold.json b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/gold.json new file mode 100644 index 00000000..42f874a1 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/gold.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:gold_block", + {"id": "#forge:storage_blocks/gold", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/iron.json b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/iron.json new file mode 100644 index 00000000..7d0543b7 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/iron.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:iron_block", + {"id": "#forge:storage_blocks/iron", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/redstone.json b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/redstone.json new file mode 100644 index 00000000..a8f29d91 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/storage_blocks/redstone.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:redstone_block", + {"id": "#forge:storage_blocks/redstone", "required": false} + ] +} diff --git a/forge-1.20.6/src/main/resources/data/c/tags/items/strings.json b/forge-1.20.6/src/main/resources/data/c/tags/items/strings.json new file mode 100644 index 00000000..7a042c57 --- /dev/null +++ b/forge-1.20.6/src/main/resources/data/c/tags/items/strings.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:string", + {"id": "#forge:string", "required": false} + ] +} 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 index eaf21a8a..fad19b67 100644 --- 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 @@ -1,5 +1,9 @@ package com.jnhprojects.mcheli.forge; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.client.CameraAngles; import com.jnhprojects.mcheli.client.ClientHooks; import com.jnhprojects.mcheli.client.particle.GlowParticle; @@ -27,9 +31,14 @@ private ForgeClient() { } static void init(IEventBus modBus) { - ClientHooks.init(); + ClientHooks.init(effect -> Minecraft.getInstance().gameRenderer.loadEffect(effect)); + modBus.addListener((FMLClientSetupEvent event) -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); + modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); - modBus.addListener((RegisterParticleProvidersEvent event) -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); + modBus.addListener((RegisterParticleProvidersEvent event) -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.Provider::new); + }); modBus.addListener((RegisterKeyMappingsEvent event) -> ClientHooks.keyMappings().forEach(event::register)); modBus.addListener((RegisterClientReloadListenersEvent event) -> ClientHooks.reloadListeners().values().forEach(event::registerReloadListener)); @@ -43,6 +52,7 @@ static void init(IEventBus modBus) { } }); forgeBus.addListener((ViewportEvent.ComputeCameraAngles event) -> ClientHooks.computeCameraAngles(new Angles(event))); + forgeBus.addListener((ViewportEvent.ComputeFov event) -> event.setFOV(ClientHooks.fov((float) event.getFOV()))); 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())); 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 index f072658a..c878e1b4 100644 --- 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 @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.forge; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; import com.jnhprojects.mcheli.CommonHooks; import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.item.VehicleIngredient; @@ -38,6 +39,7 @@ public McheliForge(FMLJavaModLoadingContext context) { helper -> helper.register(VehicleIngredient.ID, ForgeVehicleIngredient.SERIALIZER)); }); ForgeNetwork.register(); + modBus.addListener((FMLCommonSetupEvent event) -> event.enqueueWork(CommonHooks::commonSetup)); modBus.addListener((AddPackFindersEvent event) -> event.addRepositorySource(CommonHooks.packSource())); IEventBus forgeBus = MinecraftForge.EVENT_BUS; diff --git a/forge-1.21.1/src/main/resources/data/c/tags/item/feathers.json b/forge-1.21.1/src/main/resources/data/c/tags/item/feathers.json new file mode 100644 index 00000000..4554f87e --- /dev/null +++ b/forge-1.21.1/src/main/resources/data/c/tags/item/feathers.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#forge:feathers" + ] +} diff --git a/forge-1.21.1/src/main/resources/data/c/tags/item/gunpowders.json b/forge-1.21.1/src/main/resources/data/c/tags/item/gunpowders.json new file mode 100644 index 00000000..16c21708 --- /dev/null +++ b/forge-1.21.1/src/main/resources/data/c/tags/item/gunpowders.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#forge:gunpowder" + ] +} diff --git a/forge-1.21.11/build.gradle b/forge-1.21.11/build.gradle index 51b53eb9..4c2e90a6 100644 --- a/forge-1.21.11/build.gradle +++ b/forge-1.21.11/build.gradle @@ -7,12 +7,19 @@ plugins { 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 + useDefaultAccessTransformer() runs { configureEach { workingDir = rootProject.layout.projectDirectory.dir("run-${minecraft_version}") + systemProperty 'eventbus.api.strictRuntimeChecks', 'true' + systemProperty 'mcheli.legacyZip', legacyZip } register('client') @@ -32,4 +39,5 @@ repositories { dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + annotationProcessor "net.minecraftforge:eventbus-validator:${eventbus_version}" } diff --git a/forge-1.21.11/gradle.properties b/forge-1.21.11/gradle.properties index f5af7bee..c0485578 100644 --- a/forge-1.21.11/gradle.properties +++ b/forge-1.21.11/gradle.properties @@ -1,8 +1,8 @@ -# Minecraft 1.21.11 on Forge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.21.11 on Forge loader=forge minecraft_version=1.21.11 minecraft_version_range=[1.21.11,1.21.12) forge_version=61.2.4 loader_version_range=[61,) +eventbus_version=7.0.6 java_version=21 -scaffold=true diff --git a/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 00000000..369690c6 --- /dev/null +++ b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,119 @@ +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.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +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.event.sound.SoundEngineLoadEvent; +import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; + +/** Client events on Forge. Only loaded on a physical client. */ +final class ForgeClient { + private ForgeClient() { + } + + static void init(BusGroup modBus) { + ClientHooks.init(effect -> Minecraft.getInstance().gameRenderer.setPostEffect(effect)); + FMLClientSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + SoundEngineLoadEvent.BUS.addListener(event -> PackSoundBuffers.install(event.getEngine())); + RegisterParticleProvidersEvent.BUS.addListener(event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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))); + ViewportEvent.ComputeFov.BUS.addListener(event -> event.setFOV(ClientHooks.fov(event.getFOV()))); + RenderHandEvent.BUS.addListener(ForgeClient::hideHand); + RenderLivingEvent.Pre.BUS.addListener(event -> { + ClientHooks.beforeRenderLiving(event.getState(), event.getPoseStack(), ClientHooks.cameraPartialTicks()); + return false; + }); + InputEvent.InteractionKeyMappingTriggered.BUS.addListener(ForgeClient::onInteractionKey); + 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 onInteractionKey(InputEvent.InteractionKeyMappingTriggered event) { + if (event.isUseItem()) { + return switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> false; + case CONSUME -> { + event.setSwingHand(false); + yield true; + } + case CONSUME_AND_SWING -> true; + }; + } + return event.isPickBlock() && 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-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 00000000..54a48e84 --- /dev/null +++ b/forge-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 00000000..584079ac --- /dev/null +++ b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +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; +import java.util.Set; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return new BlockEntityType<>(factory::apply, Set.of(block)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 00000000..51a8d1a9 --- /dev/null +++ b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java @@ -0,0 +1,76 @@ +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 = 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) { + 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-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java index 390995f9..4028f536 100644 --- a/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java +++ b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -1,19 +1,70 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; -/** - * Placeholder entry point for Minecraft 1.21.11. It only proves that the module builds and loads; - * the Forge glue from forge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) public final class McheliForge { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + public McheliForge(FMLJavaModLoadingContext context) { + BusGroup modBus = context.getModBusGroup(); + Platform.install(new ForgePlatform()); + MCHeli.init(); - public McheliForge() { - LOGGER.info("MCHeli Rework placeholder loaded on Forge for Minecraft 1.21.11; no content yet"); + 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(); + FMLCommonSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(CommonHooks::commonSetup)); + + 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(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.11/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java new file mode 100644 index 00000000..6bf4f891 --- /dev/null +++ b/forge-1.21.11/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.sound.RuntimeSoundInstance; +import net.minecraft.client.Minecraft; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.client.sounds.SoundEngine; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.ResourceProvider; +import org.jspecify.annotations.Nullable; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +/** + * Streams sounds that only exist in a runtime root. Forge still declares + * {@code SoundInstance.getStream} on this Minecraft version, but its sound engine goes straight to + * the buffer library and never calls it, so {@code RuntimeSoundInstance} has no say here and a pack + * sound would fail to open, with nothing in the log. The field holding the library is opened by an + * access transformer. + */ +final class PackSoundBuffers extends SoundBufferLibrary { + private static final String FOLDER = "sounds/"; + private static final String EXTENSION = ".ogg"; + + private final ResourceProvider resources; + + private PackSoundBuffers(ResourceProvider resources) { + super(resources); + this.resources = resources; + } + + /** Fires on every engine (re)load, so only the first one after construction replaces anything. */ + static void install(SoundEngine engine) { + if (!(engine.soundBuffers instanceof PackSoundBuffers)) { + engine.soundBuffers = new PackSoundBuffers(Minecraft.getInstance().getResourceManager()); + } + } + + @Override + public CompletableFuture getStream(Identifier location, boolean looping) { + Path file = runtimeFile(location); + return file != null ? RuntimeSoundInstance.streamFile(file, looping) : super.getStream(location, looping); + } + + /** The disk file for {@code mcheli:sounds/.ogg}, unless a resource pack has that sound. */ + private @Nullable Path runtimeFile(Identifier location) { + String path = location.getPath(); + if (!location.getNamespace().equals(MCHeli.MOD_ID) || !path.startsWith(FOLDER) || !path.endsWith(EXTENSION) + || resources.getResource(location).isPresent()) { + return null; + } + return RuntimeAssets.soundFile(path.substring(FOLDER.length(), path.length() - EXTENSION.length())); + } +} diff --git a/forge-1.21.11/src/main/resources/META-INF/accesstransformer.cfg b/forge-1.21.11/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 00000000..ba580467 --- /dev/null +++ b/forge-1.21.11/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,4 @@ +# Vanilla keeps the post effect setter private on 1.21.11; the thermal view needs it. +public net.minecraft.client.renderer.GameRenderer setPostEffect(Lnet/minecraft/resources/Identifier;)V +# Forge's sound engine reads pack sounds through this library; see PackSoundBuffers. +public-f net.minecraft.client.sounds.SoundEngine soundBuffers diff --git a/forge-1.21.11/src/main/resources/META-INF/mods.toml b/forge-1.21.11/src/main/resources/META-INF/mods.toml index 47ec16fb..1fdc3b48 100644 --- a/forge-1.21.11/src/main/resources/META-INF/mods.toml +++ b/forge-1.21.11/src/main/resources/META-INF/mods.toml @@ -13,7 +13,6 @@ 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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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. diff --git a/forge-1.21.11/src/main/resources/data/c/tags/item/feathers.json b/forge-1.21.11/src/main/resources/data/c/tags/item/feathers.json new file mode 100644 index 00000000..4554f87e --- /dev/null +++ b/forge-1.21.11/src/main/resources/data/c/tags/item/feathers.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#forge:feathers" + ] +} diff --git a/forge-1.21.11/src/main/resources/data/c/tags/item/gunpowders.json b/forge-1.21.11/src/main/resources/data/c/tags/item/gunpowders.json new file mode 100644 index 00000000..16c21708 --- /dev/null +++ b/forge-1.21.11/src/main/resources/data/c/tags/item/gunpowders.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#forge:gunpowder" + ] +} diff --git a/forge-1.21.5/build.gradle b/forge-1.21.5/build.gradle index 51b53eb9..242d09c9 100644 --- a/forge-1.21.5/build.gradle +++ b/forge-1.21.5/build.gradle @@ -7,12 +7,18 @@ plugins { 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 + useDefaultAccessTransformer() runs { configureEach { workingDir = rootProject.layout.projectDirectory.dir("run-${minecraft_version}") + systemProperty 'mcheli.legacyZip', legacyZip } register('client') diff --git a/forge-1.21.5/gradle.properties b/forge-1.21.5/gradle.properties index 6afab053..41dc6daa 100644 --- a/forge-1.21.5/gradle.properties +++ b/forge-1.21.5/gradle.properties @@ -1,8 +1,7 @@ -# Minecraft 1.21.5 on Forge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.21.5 on Forge loader=forge minecraft_version=1.21.5 minecraft_version_range=[1.21.5,1.21.6) forge_version=55.1.18 loader_version_range=[55,) java_version=21 -scaffold=true diff --git a/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 00000000..f8b09fb4 --- /dev/null +++ b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,121 @@ +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.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +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; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; + +/** Client events on Forge. Only loaded on a physical client. */ +final class ForgeClient { + private ForgeClient() { + } + + static void init(IEventBus modBus) { + ClientHooks.init(effect -> Minecraft.getInstance().gameRenderer.setPostEffect(effect)); + modBus.addListener((FMLClientSetupEvent event) -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); + modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener((EntityRenderersEvent.RegisterRenderers event) -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener((RegisterParticleProvidersEvent event) -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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((ViewportEvent.ComputeFov event) -> event.setFOV(ClientHooks.fov((float) event.getFOV()))); + forgeBus.addListener((RenderHandEvent event) -> event.setCanceled(ClientHooks.hidesHand())); + forgeBus.addListener(EventPriority.NORMAL, false, RenderLivingEvent.Pre.class, + event -> ClientHooks.beforeRenderLiving(event.getState(), event.getPoseStack(), ClientHooks.cameraPartialTicks())); + 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.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 00000000..883dd91d --- /dev/null +++ b/forge-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 00000000..584079ac --- /dev/null +++ b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +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; +import java.util.Set; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return new BlockEntityType<>(factory::apply, Set.of(block)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 00000000..51a8d1a9 --- /dev/null +++ b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java @@ -0,0 +1,76 @@ +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 = 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) { + 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-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java index 8cb9b838..c878e1b4 100644 --- a/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java +++ b/forge-1.21.5/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -1,19 +1,76 @@ package com.jnhprojects.mcheli.forge; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; -/** - * Placeholder entry point for Minecraft 1.21.5. It only proves that the module builds and loads; - * the Forge glue from forge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) public final class McheliForge { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + public McheliForge(FMLJavaModLoadingContext context) { + IEventBus modBus = context.getModEventBus(); + Platform.install(new ForgePlatform()); + MCHeli.init(); - public McheliForge() { - LOGGER.info("MCHeli Rework placeholder loaded on Forge for Minecraft 1.21.5; no content yet"); + 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((FMLCommonSetupEvent event) -> event.enqueueWork(CommonHooks::commonSetup)); + 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.5/src/main/resources/META-INF/accesstransformer.cfg b/forge-1.21.5/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 00000000..5033c20f --- /dev/null +++ b/forge-1.21.5/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,2 @@ +# Vanilla keeps the post effect setter private on 1.21.5; the thermal view needs it. +public net.minecraft.client.renderer.GameRenderer setPostEffect(Lnet/minecraft/resources/ResourceLocation;)V diff --git a/forge-1.21.5/src/main/resources/META-INF/mods.toml b/forge-1.21.5/src/main/resources/META-INF/mods.toml index 47ec16fb..1fdc3b48 100644 --- a/forge-1.21.5/src/main/resources/META-INF/mods.toml +++ b/forge-1.21.5/src/main/resources/META-INF/mods.toml @@ -13,7 +13,6 @@ 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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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. diff --git a/forge-1.21.5/src/main/resources/data/c/tags/item/feathers.json b/forge-1.21.5/src/main/resources/data/c/tags/item/feathers.json new file mode 100644 index 00000000..4554f87e --- /dev/null +++ b/forge-1.21.5/src/main/resources/data/c/tags/item/feathers.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#forge:feathers" + ] +} diff --git a/forge-1.21.5/src/main/resources/data/c/tags/item/gunpowders.json b/forge-1.21.5/src/main/resources/data/c/tags/item/gunpowders.json new file mode 100644 index 00000000..16c21708 --- /dev/null +++ b/forge-1.21.5/src/main/resources/data/c/tags/item/gunpowders.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#forge:gunpowder" + ] +} diff --git a/forge-26.1/build.gradle b/forge-26.1/build.gradle index 372dd1f6..fe4e68cd 100644 --- a/forge-26.1/build.gradle +++ b/forge-26.1/build.gradle @@ -7,10 +7,18 @@ plugins { 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 { + useDefaultAccessTransformer() + runs { configureEach { workingDir = rootProject.layout.projectDirectory.dir("run-${minecraft_version}") + systemProperty 'eventbus.api.strictRuntimeChecks', 'true' + systemProperty 'mcheli.legacyZip', legacyZip } register('client') @@ -30,4 +38,5 @@ repositories { dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + annotationProcessor "net.minecraftforge:eventbus-validator:${eventbus_version}" } diff --git a/forge-26.1/gradle.properties b/forge-26.1/gradle.properties index 27bc7407..3428f249 100644 --- a/forge-26.1/gradle.properties +++ b/forge-26.1/gradle.properties @@ -1,8 +1,8 @@ -# Minecraft 26.1 on Forge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 26.1 on Forge loader=forge minecraft_version=26.1 minecraft_version_range=[26.1,26.2) forge_version=62.0.9 loader_version_range=[62,) +eventbus_version=7.0.6 java_version=25 -scaffold=true diff --git a/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 00000000..369690c6 --- /dev/null +++ b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,119 @@ +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.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +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.event.sound.SoundEngineLoadEvent; +import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; + +/** Client events on Forge. Only loaded on a physical client. */ +final class ForgeClient { + private ForgeClient() { + } + + static void init(BusGroup modBus) { + ClientHooks.init(effect -> Minecraft.getInstance().gameRenderer.setPostEffect(effect)); + FMLClientSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + SoundEngineLoadEvent.BUS.addListener(event -> PackSoundBuffers.install(event.getEngine())); + RegisterParticleProvidersEvent.BUS.addListener(event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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))); + ViewportEvent.ComputeFov.BUS.addListener(event -> event.setFOV(ClientHooks.fov(event.getFOV()))); + RenderHandEvent.BUS.addListener(ForgeClient::hideHand); + RenderLivingEvent.Pre.BUS.addListener(event -> { + ClientHooks.beforeRenderLiving(event.getState(), event.getPoseStack(), ClientHooks.cameraPartialTicks()); + return false; + }); + InputEvent.InteractionKeyMappingTriggered.BUS.addListener(ForgeClient::onInteractionKey); + 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 onInteractionKey(InputEvent.InteractionKeyMappingTriggered event) { + if (event.isUseItem()) { + return switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> false; + case CONSUME -> { + event.setSwingHand(false); + yield true; + } + case CONSUME_AND_SWING -> true; + }; + } + return event.isPickBlock() && 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.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 00000000..54a48e84 --- /dev/null +++ b/forge-26.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.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.1/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 00000000..1f197a36 --- /dev/null +++ b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +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; +import java.util.Set; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return new BlockEntityType<>(factory::apply, Set.of(block)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 00000000..51a8d1a9 --- /dev/null +++ b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java @@ -0,0 +1,76 @@ +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 = 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) { + 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.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java index 257d9d92..4028f536 100644 --- a/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java +++ b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -1,19 +1,70 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; -/** - * Placeholder entry point for Minecraft 26.1. It only proves that the module builds and loads; - * the Forge glue from forge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) public final class McheliForge { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + public McheliForge(FMLJavaModLoadingContext context) { + BusGroup modBus = context.getModBusGroup(); + Platform.install(new ForgePlatform()); + MCHeli.init(); - public McheliForge() { - LOGGER.info("MCHeli Rework placeholder loaded on Forge for Minecraft 26.1; no content yet"); + 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(); + FMLCommonSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(CommonHooks::commonSetup)); + + 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(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-26.1/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java new file mode 100644 index 00000000..6bf4f891 --- /dev/null +++ b/forge-26.1/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.sound.RuntimeSoundInstance; +import net.minecraft.client.Minecraft; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.client.sounds.SoundEngine; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.ResourceProvider; +import org.jspecify.annotations.Nullable; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +/** + * Streams sounds that only exist in a runtime root. Forge still declares + * {@code SoundInstance.getStream} on this Minecraft version, but its sound engine goes straight to + * the buffer library and never calls it, so {@code RuntimeSoundInstance} has no say here and a pack + * sound would fail to open, with nothing in the log. The field holding the library is opened by an + * access transformer. + */ +final class PackSoundBuffers extends SoundBufferLibrary { + private static final String FOLDER = "sounds/"; + private static final String EXTENSION = ".ogg"; + + private final ResourceProvider resources; + + private PackSoundBuffers(ResourceProvider resources) { + super(resources); + this.resources = resources; + } + + /** Fires on every engine (re)load, so only the first one after construction replaces anything. */ + static void install(SoundEngine engine) { + if (!(engine.soundBuffers instanceof PackSoundBuffers)) { + engine.soundBuffers = new PackSoundBuffers(Minecraft.getInstance().getResourceManager()); + } + } + + @Override + public CompletableFuture getStream(Identifier location, boolean looping) { + Path file = runtimeFile(location); + return file != null ? RuntimeSoundInstance.streamFile(file, looping) : super.getStream(location, looping); + } + + /** The disk file for {@code mcheli:sounds/.ogg}, unless a resource pack has that sound. */ + private @Nullable Path runtimeFile(Identifier location) { + String path = location.getPath(); + if (!location.getNamespace().equals(MCHeli.MOD_ID) || !path.startsWith(FOLDER) || !path.endsWith(EXTENSION) + || resources.getResource(location).isPresent()) { + return null; + } + return RuntimeAssets.soundFile(path.substring(FOLDER.length(), path.length() - EXTENSION.length())); + } +} diff --git a/forge-26.1/src/main/resources/META-INF/accesstransformer.cfg b/forge-26.1/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 00000000..881256a4 --- /dev/null +++ b/forge-26.1/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,4 @@ +# Vanilla keeps the post effect setter private on 26.1; the thermal view needs it. +public net.minecraft.client.renderer.GameRenderer setPostEffect(Lnet/minecraft/resources/Identifier;)V +# Forge's sound engine reads pack sounds through this library; see PackSoundBuffers. +public-f net.minecraft.client.sounds.SoundEngine soundBuffers diff --git a/forge-26.1/src/main/resources/META-INF/mods.toml b/forge-26.1/src/main/resources/META-INF/mods.toml index 47ec16fb..1fdc3b48 100644 --- a/forge-26.1/src/main/resources/META-INF/mods.toml +++ b/forge-26.1/src/main/resources/META-INF/mods.toml @@ -13,7 +13,6 @@ 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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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. diff --git a/forge-26.2/build.gradle b/forge-26.2/build.gradle index 372dd1f6..fe4e68cd 100644 --- a/forge-26.2/build.gradle +++ b/forge-26.2/build.gradle @@ -7,10 +7,18 @@ plugins { 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 { + useDefaultAccessTransformer() + runs { configureEach { workingDir = rootProject.layout.projectDirectory.dir("run-${minecraft_version}") + systemProperty 'eventbus.api.strictRuntimeChecks', 'true' + systemProperty 'mcheli.legacyZip', legacyZip } register('client') @@ -30,4 +38,5 @@ repositories { dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + annotationProcessor "net.minecraftforge:eventbus-validator:${eventbus_version}" } diff --git a/forge-26.2/gradle.properties b/forge-26.2/gradle.properties index c7d56828..2b0aa635 100644 --- a/forge-26.2/gradle.properties +++ b/forge-26.2/gradle.properties @@ -1,8 +1,8 @@ -# Minecraft 26.2 on Forge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 26.2 on Forge loader=forge minecraft_version=26.2 minecraft_version_range=[26.2,26.3) forge_version=65.1.5 loader_version_range=[65,) +eventbus_version=7.0.6 java_version=25 -scaffold=true diff --git a/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java new file mode 100644 index 00000000..369690c6 --- /dev/null +++ b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeClient.java @@ -0,0 +1,119 @@ +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.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.MenuScreens; +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.event.sound.SoundEngineLoadEvent; +import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; + +/** Client events on Forge. Only loaded on a physical client. */ +final class ForgeClient { + private ForgeClient() { + } + + static void init(BusGroup modBus) { + ClientHooks.init(effect -> Minecraft.getInstance().gameRenderer.setPostEffect(effect)); + FMLClientSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + SoundEngineLoadEvent.BUS.addListener(event -> PackSoundBuffers.install(event.getEngine())); + RegisterParticleProvidersEvent.BUS.addListener(event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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))); + ViewportEvent.ComputeFov.BUS.addListener(event -> event.setFOV(ClientHooks.fov(event.getFOV()))); + RenderHandEvent.BUS.addListener(ForgeClient::hideHand); + RenderLivingEvent.Pre.BUS.addListener(event -> { + ClientHooks.beforeRenderLiving(event.getState(), event.getPoseStack(), ClientHooks.cameraPartialTicks()); + return false; + }); + InputEvent.InteractionKeyMappingTriggered.BUS.addListener(ForgeClient::onInteractionKey); + 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 onInteractionKey(InputEvent.InteractionKeyMappingTriggered event) { + if (event.isUseItem()) { + return switch (ClientHooks.onUseKey(event.getHand())) { + case PASS -> false; + case CONSUME -> { + event.setSwingHand(false); + yield true; + } + case CONSUME_AND_SWING -> true; + }; + } + return event.isPickBlock() && 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.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeNetwork.java new file mode 100644 index 00000000..54a48e84 --- /dev/null +++ b/forge-26.2/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.2/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgePlatform.java new file mode 100644 index 00000000..d82312f3 --- /dev/null +++ b/forge-26.2/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.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java new file mode 100644 index 00000000..51a8d1a9 --- /dev/null +++ b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/ForgeVehicleIngredient.java @@ -0,0 +1,76 @@ +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 = 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) { + 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.2/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java index 0bb49087..4028f536 100644 --- a/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java +++ b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -1,19 +1,70 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegisterEvent; -/** - * Placeholder entry point for Minecraft 26.2. It only proves that the module builds and loads; - * the Forge glue from forge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +/** The Forge entry point: everything here only connects Forge to the shared code. */ +@Mod(MCHeli.MOD_ID) public final class McheliForge { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + public McheliForge(FMLJavaModLoadingContext context) { + BusGroup modBus = context.getModBusGroup(); + Platform.install(new ForgePlatform()); + MCHeli.init(); - public McheliForge() { - LOGGER.info("MCHeli Rework placeholder loaded on Forge for Minecraft 26.2; no content yet"); + 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(); + FMLCommonSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(CommonHooks::commonSetup)); + + 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(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-26.2/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java new file mode 100644 index 00000000..6bf4f891 --- /dev/null +++ b/forge-26.2/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.sound.RuntimeSoundInstance; +import net.minecraft.client.Minecraft; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.client.sounds.SoundEngine; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.ResourceProvider; +import org.jspecify.annotations.Nullable; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +/** + * Streams sounds that only exist in a runtime root. Forge still declares + * {@code SoundInstance.getStream} on this Minecraft version, but its sound engine goes straight to + * the buffer library and never calls it, so {@code RuntimeSoundInstance} has no say here and a pack + * sound would fail to open, with nothing in the log. The field holding the library is opened by an + * access transformer. + */ +final class PackSoundBuffers extends SoundBufferLibrary { + private static final String FOLDER = "sounds/"; + private static final String EXTENSION = ".ogg"; + + private final ResourceProvider resources; + + private PackSoundBuffers(ResourceProvider resources) { + super(resources); + this.resources = resources; + } + + /** Fires on every engine (re)load, so only the first one after construction replaces anything. */ + static void install(SoundEngine engine) { + if (!(engine.soundBuffers instanceof PackSoundBuffers)) { + engine.soundBuffers = new PackSoundBuffers(Minecraft.getInstance().getResourceManager()); + } + } + + @Override + public CompletableFuture getStream(Identifier location, boolean looping) { + Path file = runtimeFile(location); + return file != null ? RuntimeSoundInstance.streamFile(file, looping) : super.getStream(location, looping); + } + + /** The disk file for {@code mcheli:sounds/.ogg}, unless a resource pack has that sound. */ + private @Nullable Path runtimeFile(Identifier location) { + String path = location.getPath(); + if (!location.getNamespace().equals(MCHeli.MOD_ID) || !path.startsWith(FOLDER) || !path.endsWith(EXTENSION) + || resources.getResource(location).isPresent()) { + return null; + } + return RuntimeAssets.soundFile(path.substring(FOLDER.length(), path.length() - EXTENSION.length())); + } +} diff --git a/forge-26.2/src/main/resources/META-INF/accesstransformer.cfg b/forge-26.2/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 00000000..94e38b38 --- /dev/null +++ b/forge-26.2/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,4 @@ +# Vanilla keeps the post effect setter private on 26.2; the thermal view needs it. +public net.minecraft.client.renderer.GameRenderer setPostEffect(Lnet/minecraft/resources/Identifier;)V +# Forge's sound engine reads pack sounds through this library; see PackSoundBuffers. +public-f net.minecraft.client.sounds.SoundEngine soundBuffers diff --git a/forge-26.2/src/main/resources/META-INF/mods.toml b/forge-26.2/src/main/resources/META-INF/mods.toml index 47ec16fb..1fdc3b48 100644 --- a/forge-26.2/src/main/resources/META-INF/mods.toml +++ b/forge-26.2/src/main/resources/META-INF/mods.toml @@ -13,7 +13,6 @@ 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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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. diff --git a/forge-26.3/build.gradle b/forge-26.3/build.gradle index c0a19a24..9ce2c73b 100644 --- a/forge-26.3/build.gradle +++ b/forge-26.3/build.gradle @@ -12,6 +12,8 @@ apply from: rootProject.file('gradle/loader.gradle') def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath minecraft { + useDefaultAccessTransformer() + runs { configureEach { workingDir = rootProject.layout.projectDirectory.dir('run') 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 index 20e31893..2a211ca7 100644 --- 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 @@ -6,6 +6,7 @@ import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.registry.ModParticles; import net.minecraft.client.Camera; +import net.minecraft.client.gui.screens.MenuScreens; import net.minecraftforge.client.event.AddGuiOverlayLayersEvent; import net.minecraftforge.client.event.ClientPlayerNetworkEvent; import net.minecraftforge.client.event.EntityRenderersEvent; @@ -16,6 +17,7 @@ import net.minecraftforge.client.event.RenderHandEvent; import net.minecraftforge.client.event.RenderLivingEvent; import net.minecraftforge.client.event.ViewportEvent; +import net.minecraftforge.client.event.sound.SoundEngineLoadEvent; import net.minecraftforge.client.gui.overlay.ForgeLayeredDraw; import net.minecraftforge.event.TickEvent; import net.minecraftforge.eventbus.api.bus.BusGroup; @@ -28,9 +30,10 @@ private ForgeClient() { static void init(BusGroup modBus) { ClientHooks.init(); - FMLClientSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(ClientHooks::registerMenuScreens)); + FMLClientSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(() -> ClientHooks.registerMenuScreens(MenuScreens::register))); EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + SoundEngineLoadEvent.BUS.addListener(event -> PackSoundBuffers.install(event.getEngine())); RegisterParticleProvidersEvent.BUS.addListener(event -> { event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.Provider::new); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java new file mode 100644 index 00000000..6bf4f891 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/PackSoundBuffers.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.forge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.sound.RuntimeSoundInstance; +import net.minecraft.client.Minecraft; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.client.sounds.SoundEngine; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.ResourceProvider; +import org.jspecify.annotations.Nullable; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +/** + * Streams sounds that only exist in a runtime root. Forge still declares + * {@code SoundInstance.getStream} on this Minecraft version, but its sound engine goes straight to + * the buffer library and never calls it, so {@code RuntimeSoundInstance} has no say here and a pack + * sound would fail to open, with nothing in the log. The field holding the library is opened by an + * access transformer. + */ +final class PackSoundBuffers extends SoundBufferLibrary { + private static final String FOLDER = "sounds/"; + private static final String EXTENSION = ".ogg"; + + private final ResourceProvider resources; + + private PackSoundBuffers(ResourceProvider resources) { + super(resources); + this.resources = resources; + } + + /** Fires on every engine (re)load, so only the first one after construction replaces anything. */ + static void install(SoundEngine engine) { + if (!(engine.soundBuffers instanceof PackSoundBuffers)) { + engine.soundBuffers = new PackSoundBuffers(Minecraft.getInstance().getResourceManager()); + } + } + + @Override + public CompletableFuture getStream(Identifier location, boolean looping) { + Path file = runtimeFile(location); + return file != null ? RuntimeSoundInstance.streamFile(file, looping) : super.getStream(location, looping); + } + + /** The disk file for {@code mcheli:sounds/.ogg}, unless a resource pack has that sound. */ + private @Nullable Path runtimeFile(Identifier location) { + String path = location.getPath(); + if (!location.getNamespace().equals(MCHeli.MOD_ID) || !path.startsWith(FOLDER) || !path.endsWith(EXTENSION) + || resources.getResource(location).isPresent()) { + return null; + } + return RuntimeAssets.soundFile(path.substring(FOLDER.length(), path.length() - EXTENSION.length())); + } +} diff --git a/forge-26.3/src/main/resources/META-INF/accesstransformer.cfg b/forge-26.3/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 00000000..f48fac70 --- /dev/null +++ b/forge-26.3/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,2 @@ +# Forge's sound engine reads pack sounds through this library; see PackSoundBuffers. +public-f net.minecraft.client.sounds.SoundEngine soundBuffers diff --git a/gradle.properties b/gradle.properties index 0aca9b95..60d02c3c 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.8.1 +mod_version=1.1.0-beta.9 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-26.2,neoforge-26.2,fabric-26.2,forge-26.1,neoforge-26.1,fabric-26.1,forge-1.21.11,neoforge-1.21.11,fabric-1.21.11,forge-1.21.5,neoforge-1.21.5,fabric-1.21.5,forge-1.21.1,neoforge-1.21.1,fabric-1.21.1,forge-1.20.6,neoforge-1.20.6,fabric-1.20.6 # 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 index f79e9b48..dcd721e6 100644 --- a/gradle/loader.gradle +++ b/gradle/loader.gradle @@ -88,7 +88,7 @@ tasks.named('jar', Jar) { // resource output, which can still have copies from older builds and from the IDE. exclude { FileTreeElement element -> String path = element.relativePath.pathString - !element.directory && (path.startsWith('assets/mcheli/') || path.startsWith('data/mcheli/recipe/')) && + !element.directory && (path.startsWith('assets/mcheli/') || path.startsWith('data/mcheli/recipe/') || path.startsWith('data/mcheli/recipes/')) && !ownResources.contains(path) } includeEmptyDirs = false diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 00000000..ff258847 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 00000000..c699c4e8 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; +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.block.DispenserBlock; +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() { + } + + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + + /** + * 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. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ContentPacks.java new file mode 100644 index 00000000..d3bfdee5 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,187 @@ +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 com.jnhprojects.mcheli.content.pack.PendingPacks; +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.HashSet; +import java.util.List; +import java.util.Set; +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 static final String SOUNDS = "assets/mcheli/sounds/"; + + private final Path folder; + private final PendingPacks pending; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); + this.legacy = legacy; + } + + /** {@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); + PendingPacks.cleanReplaced(folder); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + sounds = soundsOf(accepted); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + + private static @Nullable String refusal(ContentPack pack, long size, long total) { + if (!ServerConfig.isPackAllowed(pack.id())) { + return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; + } + 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-1.20.6/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 00000000..7846b7bf --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/LegacyContent.java new file mode 100644 index 00000000..ec74f04a --- /dev/null +++ b/minecraft-1.20.6/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_20_6; + + 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.20.6/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 00000000..1a890651 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; +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.ModMenus; +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); + ModBlocks.init(); + ModBlockEntities.init(); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModMenus.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.20.6/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 00000000..1384fcc4 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,262 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * {@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
+ *   /mcheli give <players> <vehicle> [count]   (operators)
+ *   /mcheli reload                       re-read the definitions and packs (operators)
+ *   /mcheli pending                      packs players have sent, waiting (operators)
+ *   /mcheli approve <pack>               put a waiting pack in use (operators)
+ *   /mcheli reject <pack>                throw a waiting pack away (operators)
+ * 
+ */ +public final class McheliCommand { + private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); + private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); + + private McheliCommand() { + } + + 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)) + .then(Commands.literal("pending") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + } + + private static int status(CommandContext ctx) { + 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); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + + 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.20.6/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ModSounds.java new file mode 100644 index 00000000..f0536646 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -0,0 +1,64 @@ +package com.jnhprojects.mcheli; + +import net.minecraft.core.Holder; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +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. + * + *

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. + */ +public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + + private static final Map EVENTS = new ConcurrentHashMap<>(); + + private ModSounds() { + } + + public static SoundEvent get(String name) { + return EVENTS.computeIfAbsent(name, n -> SoundEvent.createVariableRangeEvent(new ResourceLocation(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) { + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); + } + + /** Plays {@code name} at a position for everyone nearby. Call on the server. */ + public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } + } + + /** Plays {@code name} for one player only, such as a hit confirmation. */ + public static void playTo(ServerPlayer player, String name, float volume, float pitch) { + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ServerConfig.java new file mode 100644 index 00000000..9db31738 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,172 @@ +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.BooleanValue INFINITE_FUEL; + 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; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; + + 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()); + INFINITE_FUEL = builder + .comment("Whether vehicles never run out of fuel. Original: InfinityFuel") + .define("infiniteFuel", false); + 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); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); + builder.pop(); + + SPEC = builder.build(); + } + + 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(); + } + + public static boolean infiniteFuel() { + return SPEC.isLoaded() ? INFINITE_FUEL.get() : INFINITE_FUEL.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 allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 00000000..7c4dbab5 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.box(0.0, 0.0, 0.0, 16.0, 15.0, 16.0); + private static final MapCodec CODEC = simpleCodec(DraftingTableBlock::new); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 00000000..56238f3f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 00000000..ab62e120 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 00000000..70bcc23e --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 00000000..3a0dafca --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,316 @@ +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.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ParachuteRenderer; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; +import com.jnhprojects.mcheli.client.screen.VehicleScreen; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Camera; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; +import net.minecraft.network.chat.Component; +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 net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; + +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 = new ResourceLocation(MCHeli.MOD_ID, "weapons"); + + private ClientHooks() { + } + + /** Loads a post effect into the game renderer, whose own method is private on some loaders. */ + public interface PostEffects { + void load(ResourceLocation effect); + } + + private static PostEffects postEffects = effect -> { + }; + + static PostEffects postEffects() { + return postEffects; + } + + /** During mod construction, on a client only. */ + public static void init(PostEffects effects) { + postEffects = effects; + 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); + renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); + } + + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + + /** On the main thread during client setup, once our menu types are registered. */ + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::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(new ResourceLocation(MCHeli.MOD_ID, "meshes"), MeshCache.INSTANCE, + new ResourceLocation(MCHeli.MOD_ID, "hud_scripts"), HudScripts.INSTANCE); + } + + public static void clientTickEnd() { + CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); + VehicleCamera.tick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + TargetMarkers.tick(); + WorkshopClient.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.setScreen(new TeamManagerScreen()); + } else { + mc.player.displayClientMessage(Component.translatable("message.mcheli.team.operators_only"), true); + } + } + + /** 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); + GltdController.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; + } + } + + /** The field of view for this frame, narrowed while the player zooms through a sight. */ + public static float fov(float fov) { + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); + } + + /** 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() || GltdController.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, float delta) { + WeaponHud.draw(graphics, delta); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.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(); + WorkshopClient.loggedOut(); + Markers.clear(); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java new file mode 100644 index 00000000..d933e518 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,338 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +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 com.jnhprojects.mcheli.network.PackSoundPacket; +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. + */ +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + 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(); + } + } + + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + 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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/CrewController.java new file mode 100644 index 00000000..54382add --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -0,0 +1,482 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +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.sounds.SoundSource; +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; + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } + 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; + VehicleCamera.reset(); + } + + 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.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } + 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 (looksThroughSight(vehicle, seat)) { + VehicleCamera.onZoomKey(player, vehicle); + } else if (pilot) { + send(vehicle, CrewActionPacket.Action.FOLD); + } + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } + } + while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { + if (!vehicle.isDestroyed()) { + send(vehicle, CrewActionPacket.Action.VEHICLE_SCREEN); + } + } + 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); + } + } + } + + /** + * Whether this seat sees through a sight that can zoom: a camera position or gunner sight, the + * pilot in gunner mode, or the pilot of a UAV. The zoom key folds the wings everywhere else. + */ + private static boolean looksThroughSight(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount() || def.cameraZoom <= 1) { + return false; + } + return eyePosition(vehicle, def, seat) != null || vehicle.isGunnerMode() || def.uav; + } + + 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.getViewYRot(partialTicks), vehicle.getViewXRot(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.getViewYRot(partialTicks)); + if (!ownPitch) { + event.setPitch(vehicle.getViewXRot(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.getViewYRot(partialTicks), vehicle.getViewXRot(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; + } + + 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.20.6/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 00000000..f755c9ae --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,104 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +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; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.screen == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphics g, float delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.drawString(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.drawString(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java new file mode 100644 index 00000000..dc594e26 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -0,0 +1,49 @@ +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); + /** Zooms through a sight, as the original Z key did; otherwise 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); + /** Fuel and parachute slots, reloading and the cargo chest. The original used R, which switches seats here. */ + public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); + /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ + public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, 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, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); + + private KeyBindings() { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 00000000..ae6ea4b5 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,235 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +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; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.screen == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, float delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(new ResourceLocation(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), (w - size) / 2, (h - size) / 2, size, size, 0.0F, 0.0F, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.drawString(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.drawString(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.drawString(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.drawString(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.drawString(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 00000000..08df6b45 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,153 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +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; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final ResourceLocation SCOPE = new ResourceLocation(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, float delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(SCOPE, (w - size) / 2, (h - size) / 2, size, size, 0.0F, 0.0F, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushPose(); + g.pose().translate(w / 2.0F, h / 2.0F, 0.0F); + g.pose().scale(k, k, 1.0F); + drawReadouts(g, mc.font, player, stack); + g.pose().popPose(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphics g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.drawString(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.drawString(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.drawString(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.drawString(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.drawString(font, ">", 30, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 00000000..dfcd0db1 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,165 @@ +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(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + 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); + } + + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(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); + } + } + + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(ResourceLocation id) { + TEXTURES.put(id, upload(id)); + } + + 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) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } + for (Path root : roots) { + Path file = inRoot(root, path); + if (file != null) { + return file; + } + } + return null; + } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 00000000..9f251d36 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.joml.Vector4f; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphics g, float delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta; + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "â–¼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "â—†", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphics g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.getMainCamera(); + Vector3fc forward = camera.getLookVector(); + Vec3 offset = at.subtract(camera.getPosition()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + // 26.x has projectPointToScreen for this; the view bobbing and the sprint widening are left out. + Vector3f view = new Vector3f((float) offset.x, (float) offset.y, (float) offset.z).rotate(camera.rotation().conjugate(new Quaternionf())); + Vector4f clip = mc.gameRenderer.getProjectionMatrix(ClientHooks.fov(mc.options.fov().get())) + .transform(new Vector4f(view, 1.0F)); + Vec3 ndc = new Vec3(clip.x() / clip.w(), clip.y() / clip.w(), clip.z() / clip.w()); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.drawCenteredString(font, symbol, (int) x, (int) y - 4, color); + g.drawCenteredString(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.drawCenteredString(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.drawCenteredString(font, "â—†", ex, ey - 4, color); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 00000000..5c6437bb --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,126 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.PostChain; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +import java.util.function.Consumer; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision and thermal view for those with {@code EnableNightVision}. Both + * reset whenever the player changes seat or leaves the vehicle. + */ +public final class VehicleCamera { + /** The original's camera modes; the HUD reads the ordinal as {@code cam_mode}. */ + public enum Mode { + NORMAL, + NIGHT_VISION, + /** Night vision seen through a white hot filter, the original's "pencil" shader mode. */ + THERMAL + } + + /** The post effect of the thermal view, {@code assets/mcheli/shaders/post/thermal.json}. */ + private static final ResourceLocation THERMAL_EFFECT = new ResourceLocation(MCHeli.MOD_ID, "shaders/post/thermal.json"); + + private static float zoom = 1.0F; + private static Mode mode = Mode.NORMAL; + + private VehicleCamera() { + } + + public static float zoom() { + return zoom; + } + + public static Mode mode() { + return mode; + } + + /** Back to the plain view, on a seat change or after leaving the vehicle. */ + static void reset() { + zoom = 1.0F; + mode = Mode.NORMAL; + } + + /** Keeps the thermal filter loaded while the thermal view is on, and only then. */ + static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + PostChain current = mc.gameRenderer.currentEffect(); + boolean active = current != null && current.getName().equals(THERMAL_EFFECT.toString()); + if (mode == Mode.THERMAL && !active) { + ClientHooks.postEffects().load(THERMAL_EFFECT); + } else if (mode != Mode.THERMAL && active) { + mc.gameRenderer.shutdownEffect(); + } + } + + /** + * {@code zoomCamera}: each press doubles the zoom up to the vehicle's {@code CameraZoom}, and + * the press after that goes back to one. The caller checks that the seat looks through a sight. + */ + static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { + refuse(player); + return; + } + zoom = zoom >= max - 0.01F ? 1.0F : Math.min(zoom * 2.0F, max); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + + /** + * {@code switchCameraMode}: steps from the plain view to night vision, to thermal view and + * back. The server applies night vision as an effect in both of the last two; the thermal + * filter is drawn here. + */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { + Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; + if (mode == Mode.NORMAL || next == Mode.NORMAL) { + nightVision.accept(next != Mode.NORMAL); + } + mode = next; + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + + /** The view's field of view, narrowed by the zoom. */ + public static float fov(float fov) { + return zoom > 1.0F ? fov / zoom : fov; + } + + /** {@code playSoundNG}: the key does nothing in this seat. */ + private static void refuse(LocalPlayer player) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java new file mode 100644 index 00000000..9507412f --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java new file mode 100644 index 00000000..d46804e6 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -0,0 +1,187 @@ +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(); + } + } + + 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.20.6/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java new file mode 100644 index 00000000..d08d5bee --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java @@ -0,0 +1,122 @@ +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.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, float 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)) { + 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.VEHICLE_SCREEN.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.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java new file mode 100644 index 00000000..ba8976c6 --- /dev/null +++ b/minecraft-1.20.6/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.20.6 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.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java new file mode 100644 index 00000000..5ed457aa --- /dev/null +++ b/minecraft-1.20.6/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 = new ResourceLocation(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.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java new file mode 100644 index 00000000..5a670dbd --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java new file mode 100644 index 00000000..3f9db748 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -0,0 +1,373 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.client.VehicleCamera; +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 read as their idle state for now: 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; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; + 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; + private int fuelWarnCount; + + /** 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.getViewYRot(partialTicks))); + v.set(HudVariable.PITCH, vehicle.getViewXRot(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, vehicle.fuelRatio()); + v.set(HudVariable.LOW_FUEL, lowFuel(vehicle)); + 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, VehicleCamera.mode().ordinal()); + v.set(HudVariable.CAM_ZOOM, VehicleCamera.zoom()); + 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); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); + } + + /** + * {@code isLowFuel}: below 10% the warning blinks, on for eight seconds out of every fourteen. + * Ticks with each update, as the original counted per frame. + */ + private boolean lowFuel(AircraftEntity vehicle) { + if (fuelWarnCount <= 0) { + fuelWarnCount = 280; + } + fuelWarnCount--; + return fuelWarnCount < 160 && vehicle.maxFuel() > 0 && vehicle.fuelRatio() < 0.1F; + } + + /** 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); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; + } else { + vars.set(HudVariable.IS_HEAT_WPN, 0.0); + vars.set(HudVariable.RELOADING, 0.0); + vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; + } + 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, mortarDistance); + } + + 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 -> mortarDistance; + 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 -> VehicleCamera.zoom(); + case UAV_DIST -> Math.max(vehicle.stationDistance(), 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.20.6/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 00000000..4d854b29 --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 00000000..3efefa3f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.util.FastColor; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends TextureSheetParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z); + setSprite(sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor((FastColor.ARGB32.red(options.color()) / 255.0F), (FastColor.ARGB32.green(options.color()) / 255.0F), (FastColor.ARGB32.blue(options.color()) / 255.0F)); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void render(VertexConsumer buffer, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.render(buffer, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + public ParticleRenderType getRenderType() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, level.random); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java new file mode 100644 index 00000000..df32bfa2 --- /dev/null +++ b/minecraft-1.20.6/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 = new ResourceLocation(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.getViewYRot(partialTicks))); + pose.mulPose(Axis.XP.rotationDegrees(-entity.getViewXRot(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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java new file mode 100644 index 00000000..2fcf06d6 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -0,0 +1,78 @@ +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; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; + public float throttle; + + // Part states, 0 to 90 degrees + public float canopy; + public float landingGear; + public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; + 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; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; + + /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ + public WeaponPartState[] weaponParts = new WeaponPartState[0]; + /** 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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java new file mode 100644 index 00000000..7849eb86 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -0,0 +1,350 @@ +package com.jnhprojects.mcheli.client.render; + +import net.minecraft.client.renderer.RenderType; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; +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.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LevelRenderer; +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.entity.Entity; +import net.minecraft.world.phys.AABB; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * 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 { + private static final float[] HULL_COLOR = {0.0F, 0.88F, 1.0F}; + private static final float[] GENERATED_COLOR = {1.0F, 0.75F, 0.0F}; + /** Ropes take the colour of brown wool. */ + private static final ResourceLocation ROPE = new ResourceLocation("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; + + /** 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.getViewYRot(partialTicks); + state.pitch = entity.getViewXRot(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 HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getViewYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getViewXRot(partialTicks) : state.pitch; + } + 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); + if (state.definition != null && entityRenderDispatcher.shouldRenderHitBoxes()) { + showHull(entity, state.definition, poseStack, buffers); + } + draw(state, poseStack, buffers); + super.render(entity, entityYaw, partialTicks, poseStack, buffers, light); + } + + /** The vehicle and what hangs off it, from a state alone; the workshop draws a model with no entity this way. */ + public static void draw(AircraftRenderState state, PoseStack poseStack, MultiBufferSource buffers) { + 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(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, buffers); + } + if (state.searchLight) { + searchLights(def, state, poseStack, buffers); + } + poseStack.popPose(); + } + } + + /** + * With F3+B on, outlines the hull boxes next to vanilla's main box: what projectiles hit and + * what other vehicles collide with. The definition's own boxes are cyan, the ones generated + * from the model amber. + */ + private static void showHull(AircraftEntity entity, AircraftDefinition def, PoseStack pose, MultiBufferSource buffers) { + List hull = entity.hullBoxes(); + if (hull.size() < 2) { + return; + } + VertexConsumer lines = buffers.getBuffer(RenderType.lines()); + int listed = def.boundingBoxes.size(); + // The first is the main box, which vanilla draws itself; then the listed boxes, then the generated ones. + for (int i = 1; i < hull.size(); i++) { + float[] c = i <= listed ? HULL_COLOR : GENERATED_COLOR; + LevelRenderer.renderLineBox(pose, lines, hull.get(i).move(-entity.getX(), -entity.getY(), -entity.getZ()), c[0], c[1], c[2], 1.0F); + } + } + + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.mulPose(Axis.YN.rotationDegrees(state.searchLightYaw - state.yaw + light.yaw())); + pose.mulPose(Axis.XP.rotationDegrees(state.searchLightPitch + 90.0F - state.pitch + light.pitch())); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.mulPose(Axis.YN.rotationDegrees(light.yaw() + steer)); + pose.mulPose(Axis.XP.rotationDegrees(90.0F + light.pitch())); + } + Glow.cone(pose, buffers, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers) { + int light = state.lightCoords; + float length = state.ropeLength; + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(ROPE)); + PoseStack.Pose p = pose.last(); + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.vertex(p, x, y, z).color(0xFFFFFFFF).uv(u, v).overlayCoords(OverlayTexture.NO_OVERLAY).uv2(light).normal(p, 0.0F, 1.0F, 0.0F).endVertex(); + } + + /** 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(new ResourceLocation(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 00000000..ae17943f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.culling.Frustum; +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; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = new ResourceLocation(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + public boolean shouldRender(ChainEntity chain, Frustum frustum, double x, double y, double z) { + Entity towed = chain.towed(); + return super.shouldRender(chain, frustum, x, y, z) || towed != null && frustum.isVisible(towed.getBoundingBox()); + } + + @Override + public void render(ChainEntity chain, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + Entity tow = chain.tow(); + Entity towed = chain.towed(); + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + if (mesh != null && tow != null && towed != null) { + Vec3 start = towed.getPosition(partialTicks); + Vec3 from = start.subtract(chain.getPosition(partialTicks)); + Vec3 span = tow.getPosition(partialTicks).subtract(start); + double length = span.length(); + if (length > LINK) { + Vec3 step = span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(TEXTURE)); + pose.pushPose(); + pose.translate(from.x, from.y, from.z); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step.x, step.y, step.z); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-yaw)); + pose.mulPose(Axis.ZP.rotationDegrees(pitch)); + MeshRenderer.emit(mesh.groups(), pose.last(), buffer, light, OverlayTexture.NO_OVERLAY, GREY); + pose.popPose(); + } + pose.popPose(); + } + } + super.render(chain, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ChainEntity chain) { + return TEXTURE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 00000000..f42386fb --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,38 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +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.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final ResourceLocation TEXTURE = new ResourceLocation(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public void render(DraftingTableBlockEntity table, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light, int overlay) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.mulPose(Axis.YP.rotationDegrees(-table.getBlockState().getValue(DraftingTableBlock.ROTATION) * 45.0F + 180.0F)); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(TEXTURE)), light, OverlayTexture.NO_OVERLAY, TINT); + pose.popPose(); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 00000000..25766956 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +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; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final ResourceLocation texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = new ResourceLocation(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + @Override + public void render(T entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + pose.mulPose(Axis.XP.rotationDegrees(entity.getViewXRot(partialTicks))); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(texture)), light, OverlayTexture.NO_OVERLAY, tint); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(T entity) { + return texture; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 00000000..4d577f09 --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java new file mode 100644 index 00000000..5be980cd --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.resources.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 final Map, Map, List>> buckets = 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)); + } + + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + + void clear() { + textures.clear(); + splits.clear(); + buckets.clear(); + } + + private Split compute(List groups, 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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 00000000..66ef04b9 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,239 @@ +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.util.Mth; +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 = new ResourceLocation(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 void begin(BufferBuilder builder, TextureManager textures) { + RenderSystem.depthMask(false); + RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES); + RenderSystem.enableBlend(); + RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE); + builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE); + } + + @Override + public void end(Tesselator tesselator) { + tesselator.end(); + } + + @Override + public String toString() { + return "MCHELI_GLOW"; + } + }; + + private static final RenderType HALO = Types.halo(); + + /** + * The particle render type for {@code mcheli:smoke}: vanilla's translucent particles with depth + * writes off. A puff's clear edge would otherwise hide the puffs drawn after it, and a smoke + * cloud is mostly overlapping puffs. + */ + public static final ParticleRenderType SMOKE_LAYER = new ParticleRenderType() { + @Override + public void begin(BufferBuilder builder, TextureManager textures) { + RenderSystem.depthMask(false); + RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE); + } + + @Override + public void end(Tesselator tesselator) { + tesselator.end(); + } + + @Override + public String toString() { + return "MCHELI_SMOKE"; + } + }; + + 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); + } + + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, MultiBufferSource buffers, float height, float radius, int start, int end) { + VertexConsumer buffer = buffers.getBuffer(HALO); + PoseStack.Pose p = pose.last(); + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + } + + 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.vertex(pose, x, y, z) + .color(argb) + .uv(u, v) + .overlayCoords(OverlayTexture.NO_OVERLAY) + .uv2(FULL_BRIGHT) + .normal(pose, 0.0F, 1.0F, 0.0F).endVertex(); + } + + /** + * 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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 00000000..fee03024 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,89 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +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; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = new ResourceLocation(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + @Override + public void render(GltdEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + float bodyYaw = entity.getViewYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + boolean drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + float headYaw = viewer != null ? viewer.getViewYRot(partialTicks) : bodyYaw; + float headPitch = viewer != null ? viewer.getViewXRot(partialTicks) : 0.0F; + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(TEXTURE)); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-bodyYaw)); + draw(mesh.part("$body"), pose, buffer, light); + pose.popPose(); + if (drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-headYaw)); + pose.mulPose(Axis.XP.rotationDegrees(headPitch)); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, buffer, light); + pose.popPose(); + } + } + if (viewer != null) { + float yaw = headYaw * Mth.DEG_TO_RAD; + float pitch = headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = entity.getPosition(partialTicks).add(head).subtract(entityRenderDispatcher.camera.getPosition()); + pose.pushPose(); + pose.translate(head.x, head.y, head.z); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, buffers, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(GltdEntity entity) { + return TEXTURE; + } + + private static void draw(List groups, PoseStack pose, VertexConsumer buffer, int light) { + if (!groups.isEmpty()) { + MeshRenderer.emit(groups, pose.last(), buffer, light, OverlayTexture.NO_OVERLAY); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java new file mode 100644 index 00000000..61c12d04 --- /dev/null +++ b/minecraft-1.20.6/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 = new ResourceLocation(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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java new file mode 100644 index 00000000..92b04403 --- /dev/null +++ b/minecraft-1.20.6/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.vertex(pose, p[a], p[a + 1], p[a + 2]) + .color(color) + .uv(uv[t], uv[t + 1]) + .overlayCoords(overlay) + .uv2(light) + .normal(pose, n[a], n[a + 1], n[a + 2]).endVertex(); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 00000000..32e51b79 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; + +import java.util.List; + +/** + * {@code MCH_RenderParachute}: the item's round canopy, or the ejection seat with its canopy, + * from the models the import brought in. + */ +public class ParachuteRenderer extends EntityRenderer { + private static final ResourceLocation ROUND = new ResourceLocation(MCHeli.MOD_ID, "textures/parachute1.png"); + private static final ResourceLocation SEAT = new ResourceLocation(MCHeli.MOD_ID, "textures/parachute2.png"); + + public ParachuteRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + @Override + public void render(ParachuteEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + List groups = groups(entity); + if (!groups.isEmpty()) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + MeshRenderer.emit(groups, pose.last(), buffers.getBuffer(RenderType.entityCutout(getTextureLocation(entity))), light, OverlayTexture.NO_OVERLAY); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ParachuteEntity entity) { + return entity.kind() == ParachuteEntity.Kind.ITEM ? ROUND : SEAT; + } + + private static List groups(ParachuteEntity entity) { + if (entity.kind() == ParachuteEntity.Kind.ITEM) { + Mesh mesh = MeshCache.INSTANCE.get("", "parachute1"); + return mesh != null ? mesh.groups() : List.of(); + } + Mesh mesh = MeshCache.INSTANCE.get("", "parachute2"); + boolean canopy = entity.kind() == ParachuteEntity.Kind.DROP || entity.isOpen(); + return mesh != null ? mesh.part(canopy ? "$parachute" : "$seat") : List.of(); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java new file mode 100644 index 00000000..d8e8cd0f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -0,0 +1,557 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +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.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +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); + hull(); + } + case HelicopterDefinition heli -> { + hull(); + rotors(heli); + } + case VehicleDefinition vehicle -> { + hull(); + for (VehiclePart part : vehicle.parts) { + vehiclePart(vehicle, part); + } + } + default -> hull(); + } + weapons(); + rotatingParts(); + movingParts(def.hatches, state.hatch); + trackRollers(); + crawlerTracks(); + steeringWheels(); + wheels(); + throttles(); + cameras(); + landingGear(); + weaponBays(); + movingParts(def.canopies, state.canopy); + } + + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + + // Kind specific parts + + private void rotors(HelicopterDefinition heli) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); + 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); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : 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; + } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + ResourceLocation own = RuntimeAssets.texture(new ResourceLocation(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderType.entityCutout(own), RenderType.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, ResourceLocation texture, RenderType opaqueType, RenderType glassType) { + 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()) { + VertexConsumer buffer = buffers.getBuffer(glassType); + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + if (state.inGui) { + MeshRenderer.emit(split.glass(), pose.last(), buffer, light, overlay, glassColor); + } else { + MeshRenderer.emitFrontFaces(split.glass(), pose.last(), buffer, 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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java new file mode 100644 index 00000000..a4564d2e --- /dev/null +++ b/minecraft-1.20.6/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.getViewYRot(partialTicks); + state.pitch = entity.getViewXRot(partialTicks); + state.visible = !entity.isSpent() && (!(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 || entity.isSpent() ? 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 = new ResourceLocation(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 new ResourceLocation(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.20.6/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java new file mode 100644 index 00000000..98ff23fd --- /dev/null +++ b/minecraft-1.20.6/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.getViewYRot(partialTicks); + float pitch = vehicle.getViewXRot(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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 00000000..1e003cb4 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,39 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +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; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + @Override + public void render(ThrowableEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + String name = entity.definitionName(); + Mesh mesh = name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", name); + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(getTextureLocation(entity))), light, OverlayTexture.NO_OVERLAY); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ThrowableEntity entity) { + return new ResourceLocation(MCHeli.MOD_ID, "textures/throwable/" + entity.definitionName() + ".png"); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 00000000..077cb1ea --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +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; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + @Override + public void render(UavStationEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + int index = entity.kind() == UavStationEntity.PORTABLE ? 1 : 0; + float cover = entity.cover(partialTicks); + boolean lit = index == 1 ? cover > 0.95F : entity.isLinked(); + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + ResourceLocation texture = new ResourceLocation(MCHeli.MOD_ID, "textures/" + MODEL[index] + (lit ? "_on" : "") + ".png"); + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(texture)); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + if (index == 0) { + draw(pose, buffer, mesh.groups(), light); + } else { + draw(pose, buffer, mesh.part("$body"), light); + folding(pose, buffer, mesh.part("$cover"), cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, buffer, mesh.part("$laptop_cover"), cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, buffer, mesh.part("$display"), cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(UavStationEntity entity) { + return new ResourceLocation(MCHeli.MOD_ID, "textures/" + MODEL[entity.kind() == UavStationEntity.PORTABLE ? 1 : 0] + ".png"); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, VertexConsumer buffer, List part, float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.mulPose(Axis.XN.rotationDegrees(angle)); + pose.translate(0.0F, -y, -z); + draw(pose, buffer, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, VertexConsumer buffer, List groups, int light) { + if (!groups.isEmpty()) { + MeshRenderer.emit(groups, pose.last(), buffer, light, OverlayTexture.NO_OVERLAY, TINT); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 00000000..52af9514 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,326 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.item.crafting.ShapedRecipe; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the client's recipe manager, which the server fills on join. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = new ResourceLocation(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List>> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 400; + imageHeight = 240; + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); + selectList(list); + } + + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.setScreen(new WorkshopHomeScreen()); + } + + /** Every MCHeli crafting recipe, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + for (RecipeHolder holder : minecraft.level.getRecipeManager().getAllRecipesFor(RecipeType.CRAFTING)) { + ItemStack result = resultOf(holder); + if (holder.id().getNamespace().equals(MCHeli.MOD_ID) && !result.isEmpty() + && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(holder); + } + } + for (List> entries : lists) { + entries.sort(Comparator.comparing(e -> resultOf(e).getHoverName().getString())); + } + } + + private ItemStack resultOf(RecipeHolder holder) { + return holder.value().getResultItem(minecraft.level.registryAccess()); + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeHolder current() { + List> entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeHolder entry = current(); + return entry != null ? resultOf(entry) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static ResourceLocation pageTexture(String name, int page) { + return new ResourceLocation(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeHolder entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedContents contents = new StackedContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return contents.canCraft(entry.value(), null); + } + + private void create() { + RecipeHolder entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, PAGE_WIDTH / 2, PAGE_HEIGHT / 2, 0.0F, 0.0F, + PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeHolder entry = current(); + if (entry == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.drawString(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.drawString(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.value(), mouseX, mouseY); + graphics.renderItem(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphics graphics, CraftingRecipe recipe, int mouseX, int mouseY) { + List slots = recipe.getIngredients(); + int width = recipe instanceof ShapedRecipe shaped ? shaped.getWidth() : 3; + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + ItemStack[] options = slots.get(i).getItems(); + if (options.length == 0) { + continue; + } + ItemStack stack = options[tick % options.length]; + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.renderItem(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.renderTooltip(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphics graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.drawString(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 00000000..3ec2606f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,136 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamManager.COLORS.indexOf(ChatFormatting.RED); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamManager.COLORS.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + ChatFormatting teamColor = TeamManager.COLORS.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getName()).withStyle(teamColor))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphics graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.drawString(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.drawString(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 00000000..118d3eb5 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = new ResourceLocation(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 166; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.drawString(font, label, 8, 6, color, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.drawString(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java new file mode 100644 index 00000000..006e92af --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java @@ -0,0 +1,186 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGui}: fuel and parachute slots on the left, the weapon + * panel on the right with its reload cost and button, and a button into the cargo chest for + * vehicles that have one. The panel is drawn from the chest texture so it looks like vanilla. + */ +public class VehicleScreen extends AbstractContainerScreen { + private static final ResourceLocation CHEST = new ResourceLocation("textures/gui/container/generic_54.png"); + private static final ResourceLocation SLOT = new ResourceLocation("container/slot"); + /** Height of the part above the player inventory. */ + private static final int TOP = 88; + private static final int PANEL_X = 72; + private static final int PANEL_WIDTH = 96; + private static final int LABEL = 0xFF404040; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF5555; + private static final int GREEN = 0xFF55FF55; + + private int weapon; + private boolean cargoButton; + private @Nullable Button reload; + private @Nullable Button previous; + private @Nullable Button next; + + public VehicleScreen(VehicleMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = TOP + 96; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + previous = addRenderableWidget(Button.builder(Component.literal("<"), b -> step(-1)) + .bounds(leftPos + PANEL_X, topPos + 68, 16, 16).build()); + reload = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.reload"), b -> press(weapon)) + .bounds(leftPos + PANEL_X + 18, topPos + 68, PANEL_WIDTH - 36, 16).build()); + next = addRenderableWidget(Button.builder(Component.literal(">"), b -> step(1)) + .bounds(leftPos + PANEL_X + PANEL_WIDTH - 16, topPos + 68, 16, 16).build()); + AircraftEntity vehicle = menu.vehicle(); + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + cargoButton = def != null && def.cargoRows() > 0; + if (cargoButton) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.cargo"), b -> press(VehicleMenu.BUTTON_CARGO)) + .bounds(leftPos + imageWidth - 52, topPos + 3, 46, 13).build()); + } + updateButtons(); + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + private int weaponCount() { + AircraftEntity vehicle = menu.vehicle(); + VehicleWeapons weapons = vehicle != null ? vehicle.weapons() : null; + return weapons != null ? Math.min(weapons.sets().size(), VehicleMenu.MAX_WEAPONS) : 0; + } + + private void step(int direction) { + int count = weaponCount(); + if (count > 0) { + weapon = Math.floorMod(weapon + direction, count); + updateButtons(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + private void updateButtons() { + if (reload == null || previous == null || next == null) { + return; + } + int count = weaponCount(); + reload.visible = count > 0; + previous.visible = count > 1; + next.visible = count > 1; + reload.active = menu.canReload(weapon); + AircraftEntity vehicle = menu.vehicle(); + boolean grounded = vehicle != null && vehicle.canSupply(); + reload.setTooltip(grounded ? null : Tooltip.create(Component.translatable("gui.mcheli.vehicle.reload_hint"))); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + int x = leftPos; + int y = topPos; + // The chest's title bar, its plain middle stretched down to the inventory, then the inventory. + graphics.blit(CHEST, x, y, 0.0F, 0.0F, imageWidth, 17, 256, 256); + graphics.blit(CHEST, x, y + 17, imageWidth, TOP - 17, 0.0F, 12.0F, imageWidth, 1, 256, 256); + graphics.blit(CHEST, x, y + TOP, 0.0F, 126.0F, imageWidth, 96, 256, 256); + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + graphics.blitSprite(SLOT, x + VehicleMenu.FUEL_X + i * 18 - 1, y + VehicleMenu.FUEL_Y - 1, 18, 18); + } + for (int i = 0; i < menu.parachuteSlots(); i++) { + graphics.blitSprite(SLOT, x + VehicleMenu.PARACHUTE_X + i * 18 - 1, y + VehicleMenu.PARACHUTE_Y - 1, 18, 18); + } + graphics.fill(x + PANEL_X - 2, y + 17, x + PANEL_X + PANEL_WIDTH + 2, y + 66, 0xFF373737); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + AircraftEntity vehicle = menu.vehicle(); + // Long names only give way to the Cargo button, where there is one. + int titleWidth = cargoButton ? imageWidth - 64 : imageWidth - 16; + graphics.drawString(font, font.plainSubstrByWidth(title.getString(), titleWidth), titleLabelX, titleLabelY, LABEL, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, LABEL, false); + if (vehicle == null) { + return; + } + if (vehicle.maxFuel() > 0) { + // A percentage and a gauge: the exact units would run into the weapon panel. + int percent = Math.round(vehicle.fuelRatio() * 100.0F); + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.fuel", percent), VehicleMenu.FUEL_X, VehicleMenu.FUEL_Y - 11, LABEL, false); + int gaugeY = VehicleMenu.FUEL_Y + 18; + int width = VehicleInventory.FUEL_SLOTS * 18 - 2; + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width, gaugeY + 3, 0xFF373737); + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width * percent / 100, gaugeY + 3, percent <= 10 ? RED : GREEN); + } + if (menu.parachuteSlots() > 0) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.parachute"), VehicleMenu.PARACHUTE_X, VehicleMenu.PARACHUTE_Y - 11, LABEL, false); + } + weaponPanel(graphics, vehicle); + } + + /** Name, rounds carried against the maximum, and what one reload costs. */ + private void weaponPanel(GuiGraphics graphics, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null && weaponCount() > 0 ? weapons.set(Math.min(weapon, weaponCount() - 1)) : null; + if (set == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.no_weapons"), PANEL_X, 20, WHITE, false); + return; + } + graphics.drawString(font, font.plainSubstrByWidth(set.displayName(), PANEL_WIDTH), PANEL_X, 20, WHITE, false); + int rounds = menu.rounds(weapon); + int max = set.maxAmmo(); + int color = rounds == 0 && max > 0 ? RED : rounds >= max ? GREEN : WHITE; + graphics.drawString(font, rounds + " / " + max, PANEL_X, 31, color, false); + int itemX = PANEL_X; + for (RoundItem cost : set.definition().roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null) { + continue; + } + graphics.renderItem(new ItemStack(item), itemX, 42); + graphics.drawString(font, String.valueOf(cost.count()), itemX + 17, 47, WHITE, false); + itemX += 32; + if (itemX > PANEL_X + PANEL_WIDTH - 16) { + break; + } + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java new file mode 100644 index 00000000..b32040f5 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. + * + *

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. + */ +final class EngineSound extends RuntimeSoundInstance { + private static final float MAX_DOPPLER = 0.2F; + + private final AircraftEntity vehicle; + private final EngineSoundSource source; + private final boolean rotor; + private float doppler; + private double lastDistance = -1.0; + + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.vehicle = vehicle; + this.source = source; + this.rotor = rotor; + this.looping = true; + this.delay = 0; + update(); + } + + @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 = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); + boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); + if (aboard) { + x = vehicle.getX(); + 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-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java new file mode 100644 index 00000000..62e52284 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -0,0 +1,75 @@ +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 and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. + */ +public final class EngineSoundPlayer { + /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ + private static final int RESTART_DELAY = 20; + + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); + + private EngineSoundPlayer() { + } + + 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; + } + AircraftDefinition def = vehicle.definition(); + if (def == null) { + continue; + } + double range = def.soundRange * 1.2; + if (vehicle.distanceToSqr(player) > range * range) { + continue; + } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); + } + } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 00000000..49c53d61 --- /dev/null +++ b/minecraft-1.20.6/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.toString(), 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.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..26401727 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; + +/** + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, so the undo history and the view are + * as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + + public static void tick() { + tickSubmit(); + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.screen == null) { + mc.setScreen(screen); + } + } + } + + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + + public static void loggedOut() { + submitting = null; + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..cc44546a --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,201 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.home.packs"), b -> minecraft.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().getName() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.drawString(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.drawString(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..b8abc314 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import net.minecraft.Util; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Util.getPlatform().openFile(folder.toFile()); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.setScreen(new WorkshopHomeScreen()); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + if (rows.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphics graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphics graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.drawString(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..2b29f220 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1671 @@ +package com.jnhprojects.mcheli.client.workshop; + +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import org.lwjgl.opengl.GL11; +import org.joml.Matrix4f; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.LightTexture; +import com.mojang.math.Axis; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.platform.Lighting; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.AssetImport; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

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

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

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.20.6 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); + private static final EntityDataAccessor DATA_FUEL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + /** The UAV station a drone is flown from, by entity id, or 0. */ + private static final EntityDataAccessor DATA_STATION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_REPELLING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SEARCH_LIGHT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + /** Marks a player flying a drone from a station, so a stale hide can be undone at login. */ + public static final String REMOTE_PILOT_TAG = "mcheli_remote_pilot"; + + // 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<>(); + /** The station a drone is flown from, and where its pilot goes back to. Server only. */ + private @Nullable UUID station; + private @Nullable Vec3 stationReturn; + /** Remote pilots leaving this tick, by entity id, with where they come back down. */ + private final Map remoteReturns = new HashMap<>(); + /** Where a workshop test flight started, or {@code null} for an ordinary vehicle. Server only. */ + private @Nullable Vec3 testFlightStart; + private boolean testFlightOver; + /** How far the ropes hang below their hooks, as a negative number; both sides let them out. */ + private float ropeLength; + private int repellingTicks; + private int lastRope; + /** Passengers are leaving one by one under parachutes. Server only. */ + private boolean droppingTroops; + + 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; + /** The fuel load from which an aircraft counts as heavy: the H8K flying boat and up. */ + private static final int HEAVY_FUEL = 3000; + /** 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; + /** Ticks since this aircraft was shot down, for the crash spin. Counted on both sides. */ + private int spinTicks; + 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; + + /** Fuel burnt but not yet taken from the tank, which only holds whole units. Server only. */ + private double fuelBurnt; + /** Ticks a fuel truck nearby keeps this vehicle from burning fuel. Server only. */ + private int fuelSuppliedCount; + /** One bit per seat whose occupant has night vision on. Server only, not saved. */ + private int nightVisionSeats; + /** Fuel and parachute slots and the cargo chest. Only the server's copy holds anything. */ + private final VehicleInventory inventory = new VehicleInventory(this); + + // 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; + /** + * How far a hull reaches past its main box, at most, when looking for neighbours: other + * vehicles are found by their main box, and the longest hulls stretch about this far from it. + */ + private static final double HULL_REACH = 24.0; + /** 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); + builder.define(DATA_FUEL, 0); + builder.define(DATA_STATION, 0); + builder.define(DATA_REPELLING, false); + builder.define(DATA_SEARCH_LIGHT, 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); + entityData.set(DATA_FUEL, definition.maxFuel); + 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); + } + + /** + * {@code repair}: adds hit points up to the maximum. A wreck stays a wreck. Server only. + * + * @return whether anything was repaired + */ + public boolean repair(int amount) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || hp() >= def.maxHp) { + return false; + } + entityData.set(DATA_HP, Math.min(hp() + amount, def.maxHp)); + return true; + } + + /** + * {@code switchNextTextureName}: the next paint scheme the definition lists. Server only. + * + * @return false when the vehicle has only one + */ + public boolean nextTexture() { + AircraftDefinition def = definition(); + if (def == null || def.textures().size() < 2) { + return false; + } + List textures = def.textures(); + int next = (textures.indexOf(texture()) + 1) % textures.size(); + entityData.set(DATA_TEXTURE, textures.get(next)); + return true; + } + + // Fuel + + public int fuel() { + return entityData.get(DATA_FUEL); + } + + public int maxFuel() { + AircraftDefinition def = definition(); + return def != null ? def.maxFuel : 0; + } + + /** {@code getFuelP}: how full the tank is, 0 to 1. Vehicles without a tank read as full. */ + public float fuelRatio() { + int max = maxFuel(); + return max <= 0 ? 1.0F : (float) fuel() / max; + } + + /** {@code canUseFuel}: a vehicle without a tank never runs dry. */ + public boolean hasFuel() { + return maxFuel() <= 0 || fuel() > 1; + } + + /** Sets the tank outright; a drone leaves its station with 5%. Server only. */ + public void setFuelLevel(int fuel) { + entityData.set(DATA_FUEL, Math.clamp(fuel, 0, Math.max(maxFuel(), 0))); + } + + /** + * Pours up to {@code amount} into the tank. Server only. + * + * @return how much went in + */ + public int refuel(int amount) { + int added = Math.clamp(maxFuel() - fuel(), 0, Math.max(amount, 0)); + if (added > 0) { + entityData.set(DATA_FUEL, fuel() + added); + } + return added; + } + + // Night vision + + /** A crew member switched night vision on or off. Server only. */ + public void setNightVision(Player player, boolean on) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (seat < 0 || seat >= 31) { + return; + } + if (on && def != null && def.nightVision && !isDestroyed()) { + nightVisionSeats |= 1 << seat; + } else if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(player); + } + } + + /** Keeps night vision on for everyone in the crew who switched it on, see {@link NightVision}. */ + private void tickNightVision() { + if (nightVisionSeats == 0) { + return; + } + if (isDestroyed()) { + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (crew != null) { + NightVision.end(crew); + } + } + nightVisionSeats = 0; + return; + } + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (hasNightVision(seat) && crew != null) { + NightVision.topUp(crew); + } + } + } + + /** Only the first 31 seats can switch it on; racks and the like come after them anyway. */ + private boolean hasNightVision(int seat) { + return seat >= 0 && seat < 31 && (nightVisionSeats & 1 << seat) != 0; + } + + /** + * {@code updateFuel}: once a second the engine burns its {@code FuelConsumption}, scaled by the + * throttle and topping out at 70% throttle. Nothing burns next to a fuel truck or with the + * server's infinite fuel on. + */ + private void burnFuel(AircraftDefinition def) { + if (def.maxFuel <= 0 || isDestroyed()) { + return; + } + if (fuelSuppliedCount > 0) { + fuelSuppliedCount--; + return; + } + if (ticksAlive % 20 != 0 || fuel() <= 1 || throttle <= 0.0 || ServerConfig.infiniteFuel()) { + return; + } + fuelBurnt += Math.min(throttle * 1.4, 1.0) * def.fuelConsumption; + if (fuelBurnt > 1.0) { + int whole = (int) fuelBurnt; + fuelBurnt -= whole; + entityData.set(DATA_FUEL, Math.max(fuel() - whole, 0)); + } + } + + /** + * {@code supplyFuel}: a fuel truck ({@code FuelSupplyRange}) tops up every vehicle within range, + * 30 units every half second. While it is on the ground it only reaches vehicles that are on + * the ground too. + */ + private void supplyFuel(AircraftDefinition def) { + float range = def.fuelSupplyRange; + if (range <= 0.0F || ticksAlive % 10 != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + if (other == this) { + continue; + } + if (!onGround() || other.onGround()) { + other.refuel(30); + } + other.fuelSuppliedCount = 40; + } + } + + // Inventory and supply + + public VehicleInventory inventory() { + return inventory; + } + + /** + * {@code canSupply}: fuel cans and resupply only work while the vehicle stands on something, + * within three blocks; a vehicle that cannot float must not be in the water either. + */ + public boolean canSupply() { + AircraftDefinition def = definition(); + return def != null && hasBlockBelow(1, 3, false) && (def.floats || !isInWater()); + } + + /** {@code openGui}: shows a crew member the vehicle screen. Server only. */ + public void openScreen(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || player.getVehicle() != this) { + return; + } + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new VehicleMenu(id, playerInventory, this), displayName(def))); + } + + /** + * {@code openInventory}: shows the cargo chest of a vehicle with an {@code InventorySize}. + * Server only. + * + * @return false when the vehicle has none + */ + public boolean openCargo(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || def.cargoRows() <= 0 || isDestroyed() || !inventory.stillValid(player)) { + return false; + } + int rows = Math.min(def.cargoRows(), 6); + MenuType type = switch (rows) { + case 1 -> MenuType.GENERIC_9x1; + case 2 -> MenuType.GENERIC_9x2; + case 3 -> MenuType.GENERIC_9x3; + case 4 -> MenuType.GENERIC_9x4; + case 5 -> MenuType.GENERIC_9x5; + default -> MenuType.GENERIC_9x6; + }; + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new ChestMenu(type, id, playerInventory, inventory.cargo(), rows), + displayName(def))); + return true; + } + + /** The vehicle's name as its item shows it. */ + private static Component displayName(AircraftDefinition def) { + return Component.translatableWithFallback("item.mcheli." + def.name(), def.displayName); + } + + /** + * {@code supplyAmmoToOtherAircraft}: an ammunition truck ({@code AmmoSupplyRange}) tops up the + * weapons of every vehicle within range that stands on something, a tenth of each weapon's + * maximum every two seconds. + */ + private void supplyAmmo(AircraftDefinition def) { + float range = def.ammoSupplyRange; + if (range <= 0.0F || ticksAlive % SupplyRules.AMMO_TRUCK_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + VehicleWeapons theirs = other.weapons(); + if (other == this || theirs == null || other.isDestroyed() || !other.canSupply()) { + continue; + } + for (WeaponSet set : theirs.sets()) { + int rounds = SupplyRules.roundsFromTruck(set.ammo() + set.restAmmo(), set.maxAmmo()); + if (rounds > 0) { + set.setRestAmmo(set.restAmmo() + rounds); + if (set.ammo() <= 0) { + set.reloadMagazine(); + } + } + } + } + } + + /** {@code repairOtherAircraft}: a repair vehicle ({@code RepairOtherVehicles}) mends those in range. */ + private void repairOthers(AircraftDefinition def) { + float range = def.repairOtherVehiclesRange; + if (range <= 0.0F || ticksAlive % SupplyRules.REPAIR_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + AircraftDefinition theirs = other.definition(); + if (other != this && theirs != null) { + other.repair(SupplyRules.repair(other.hp(), theirs.maxHp, def.repairOtherVehiclesValue)); + } + } + } + + /** 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 + + /** + * Makes this a workshop test flight: it takes no damage, is not saved with the world, and goes + * away once its pilot gets out, putting them back where they started. + */ + public void startTestFlight(Vec3 start) { + testFlightStart = start; + } + + @Override + public boolean shouldBeSaved() { + return testFlightStart == null && super.shouldBeSaved(); + } + + @Override + public void tick() { + if (testFlightOver) { + discard(); + return; + } + rollO = roll(); + if (getHurtTime() > 0) { + setHurtTime(getHurtTime() - 1); + } + super.tick(); + if (level().isClientSide()) { + tickLerp(); + } + ticksAlive++; + lastSeats.clear(); + remoteReturns.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 (!level().isClientSide()) { + supplyFuel(def); + supplyAmmo(def); + repairOthers(def); + burnFuel(def); + if (def.maxFuel > 0 && fuel() < def.maxFuel && !isDestroyed()) { + inventory.pourFuelCans(ticksAlive); + } + tickNightVision(); + tickStation(); + } + tickRopes(def); + + if (isSimulatingHere()) { + if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { + input.clear(); + } + if (isRepelling()) { + // {@code MCH_AircraftClientTickHandler}: the pilot holds a hover while the ropes are out. + input.throttleUp = false; + input.throttleDown = false; + input.left = false; + input.right = false; + } + if (!hasFuel()) { + // An empty tank: the throttle runs down by itself, as with the pilot's hand off it. + input.throttleUp = false; + } + 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); + VehicleParticles.tick(this, 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) { + // A blocked move can leave the hulls up to a tick of travel apart; when the vehicle + // stopped short this tick, look that far for what stopped it. + boolean stopped = velocity.length() < previous.length() * 0.5; + double reach = stopped ? 0.5 + previous.length() : 0.5; + for (AircraftEntity other : solidNeighbours(hullBounds().inflate(reach))) { + if (!hullsTouch(other, reach)) { + continue; + } + 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() || current == 0 && station != null) { + // A remote pilot stays at the controls; they are not really aboard. + 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); + } + + // UAV stations + + /** Whether a player at a station could take this drone's controls now. */ + public boolean canTakeRemotePilot() { + AircraftDefinition def = definition(); + return def != null && def.uav && !def.targetDrone && !isDestroyed() && isSeatFree(0); + } + + /** + * Seats {@code player}, who sits at {@code from}, at this drone's controls. The pilot is hidden + * and takes no harm while flying, since in the original they never left the station. Server only. + */ + public boolean takeRemotePilot(ServerPlayer player, UavStationEntity from) { + if (!canTakeRemotePilot()) { + return false; + } + pendingSeat = 0; + boolean seated = player.startRiding(this, true); + pendingSeat = EMPTY; + if (!seated) { + return false; + } + station = from.getUUID(); + stationReturn = from.returnPosition(); + entityData.set(DATA_STATION, from.getId()); + player.addTag(REMOTE_PILOT_TAG); + player.setInvisible(true); + player.setInvulnerable(true); + return true; + } + + /** Sends the remote pilot back to the station, if one is flying. Server only. */ + public void releaseRemotePilot() { + Entity pilot = occupant(0); + if (station != null && pilot != null) { + pilot.stopRiding(); + } + } + + /** The station this drone is flown from, as the client sees it. */ + public @Nullable UavStationEntity stationEntity() { + return level().getEntity(entityData.get(DATA_STATION)) instanceof UavStationEntity s ? s : null; + } + + /** Flat distance to the station this drone is flown from, or -1 without one. */ + public double stationDistance() { + UavStationEntity from = stationEntity(); + if (from == null) { + return -1.0; + } + double dx = getX() - from.getX(); + double dz = getZ() - from.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } + + /** Whether {@code entity} flies this drone from a station rather than sitting in it. */ + public boolean isRemotePilot(Entity entity) { + return (station != null || entityData.get(DATA_STATION) != 0) && occupant(0) == entity; + } + + private void endRemoteControl(Entity pilot) { + UavStationEntity from = level() instanceof ServerLevel server && station != null + && server.getEntity(station) instanceof UavStationEntity s ? s : null; + Vec3 home = from != null ? from.returnPosition() : stationReturn; + if (home != null) { + remoteReturns.put(pilot.getId(), home); + } + clearRemotePilot(pilot); + if (from != null) { + from.setLinked(false); + } + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + } + + /** Undoes what {@link #takeRemotePilot} did to the player. */ + public static void clearRemotePilot(Entity pilot) { + if (pilot.removeTag(REMOTE_PILOT_TAG)) { + pilot.setInvisible(false); + pilot.setInvulnerable(false); + } + } + + /** + * {@code updateUAV}: the link breaks when the station is gone, and flying out of its range + * loses the drone, as in the original. + */ + private void tickStation() { + if (station == null || !(level() instanceof ServerLevel server)) { + return; + } + Entity pilot = occupant(0); + if (pilot == null) { + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + return; + } + UavStationEntity from = server.getEntity(station) instanceof UavStationEntity s && s.isAlive() ? s : null; + if (from == null) { + pilot.stopRiding(); + return; + } + entityData.set(DATA_STATION, from.getId()); + if (!from.inRange(this) && !isDestroyed()) { + if (pilot instanceof Player player) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.lost"), true); + } + pilot.stopRiding(); + hurt(damageSources().fellOutOfWorld(), hp() + 10.0F); + } + } + + @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); + } + // Coming back aboard after a reload, a remote pilot included. + Integer saved = savedSeats.get(passenger.getUUID()); + if (saved != null && isSeatFree(saved)) { + return true; + } + // Cargo only boards through a rack, never into a crew seat. + return !(passenger instanceof AircraftEntity || passenger instanceof ContainerEntity) && 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) { + if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(passenger); + } + lastSeats.put(passenger.getId(), seat); + seatIds[seat] = EMPTY; + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onLeft(seat); + } + } + if (seat == 0) { + input.clear(); + if (station != null) { + endRemoteControl(passenger); + } + if (testFlightStart != null) { + remoteReturns.put(passenger.getId(), testFlightStart); + testFlightOver = true; + } + } + } + + @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); + } else if (def != null && passenger instanceof ContainerEntity container) { + RackDefinition rack = def.rack(seatOf(passenger)); + container.setYRot(getYRot() + (rack != null ? rack.yaw() : 0.0F)); + container.setDeltaMovement(Vec3.ZERO); + } + } + + @Override + public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + Vec3 home = remoteReturns.remove(passenger.getId()); + if (home != null) { + return home; + } + 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.20.6 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) { + ItemStack held = player.getItemInHand(hand); + if (held.getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (definition() != null && (held.getItem() instanceof FuelItem || held.getItem() instanceof WrenchItem)) { + if (level().isClientSide()) { + return InteractionResult.SUCCESS; + } + return held.getItem() instanceof FuelItem ? FuelItem.pour(held, this, player) : WrenchItem.useOn(held, this, player, hand); + } + InteractionResult result = super.interact(player, hand); + AircraftDefinition def = definition(); + if (result == InteractionResult.PASS && player.isSecondaryUseActive() && def != null && def.cargoRows() > 0 + && !isDestroyed() && player.getVehicle() != this) { + // Sneaking opens the cargo chest from outside, as in the original. + if (player instanceof ServerPlayer server) { + openCargo(server); + } + return InteractionResult.SUCCESS; + } + if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { + return result; + } + 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(); + boardNearbyMobs(); + } + } + return InteractionResult.SUCCESS; + } + + // Racks + + /** Loads {@code cargo} into the given rack seat. Server only. */ + private boolean loadCargo(Entity 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.value(), 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; + } + + /** Whether rack {@code seat} carries containers, {@code AddRack} naming {@code container}. */ + public boolean acceptsContainer(ContainerEntity container, int seat) { + AircraftDefinition def = definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + return rack != null && !isPassenger() && !container.isPassenger() + && rack.accepts(ContainerEntity.CARGO_NAME, ContainerEntity.CARGO_NAME); + } + + 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 or container 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()); + boolean loaded = false; + for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { + if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + loaded = true; + break; + } + } + if (!loaded) { + for (ContainerEntity container : level().getEntitiesOfClass(ContainerEntity.class, area)) { + if (acceptsContainer(container, seat) && loadCargo(container, 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); + // As in the original, with no ground in the 20 blocks below it goes down under a canopy. + if (!(cargo instanceof AircraftEntity) && !hasBlockBelow(3, 20, false)) { + ParachuteEntity.open(level(), cargo, ParachuteEntity.Kind.DROP); + } + 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); + } + + /** + * {@code ejectSeat}: throws the occupant clear of the aircraft in an ejection seat, whose + * parachute opens once it starts to fall. Each ejection takes a parachute from the parachute + * slots of the vehicle screen; without one the seat stays put. Creative players need none. + * Server only. + */ + public void eject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + if (def == null || !canEject(entity)) { + return; + } + boolean creative = entity instanceof Player player && player.getAbilities().instabuild; + if (!creative && !inventory.takeParachute()) { + if (entity instanceof ServerPlayer player) { + ModSounds.playTo(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.no_parachute"), true); + } + 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; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.EJECTION_SEAT); + if (canopy != null && !canopy.status()) { + canopy.set(true, false); + modeSwitchCooldown = 40; + } + ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); + } + + // Search lights + + public boolean isSearchLightOn() { + AircraftDefinition def = definition(); + return def != null && !def.searchLights.isEmpty() && entityData.get(DATA_SEARCH_LIGHT); + } + + /** {@code canSwitchSearchLight}: the pilot or the first crew seat, in vehicles that have one. */ + public boolean canSwitchSearchLight(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && !def.searchLights.isEmpty() && seat >= 0 && seat <= 1; + } + + /** Server only. */ + public void toggleSearchLight(Entity entity) { + if (canSwitchSearchLight(entity)) { + entityData.set(DATA_SEARCH_LIGHT, !entityData.get(DATA_SEARCH_LIGHT)); + } + } + + /** + * Where a turning search light points: the first crew seat's view, else the pilot's, as + * {@code renderLight} took them. Null with nobody aboard to aim it. + */ + public @Nullable Entity searchLightAimer() { + Entity crew = occupant(1); + return crew != null ? crew : occupant(0); + } + + // Troops: parachute drops and ropes + + public boolean isRepelling() { + return entityData.get(DATA_REPELLING); + } + + /** How far the ropes hang below their hooks, as a negative number. */ + public float ropeLength() { + return ropeLength; + } + + /** Where rope {@code index} hangs from, in the world. */ + public Vec3 ropePosition(int index) { + AircraftDefinition def = definition(); + return def != null && index >= 0 && index < def.repellingHooks.size() ? toWorld(def.repellingHooks.get(index).position()) : position(); + } + + /** + * {@code onUpdate_Repelling} and {@code onUpdate_UnmountCrew}: the ropes run out towards the + * ground, then the mobs aboard slide down them one per hook interval; a parachute drop lets one + * passenger out per {@code MobDropOption} interval while the hatch is open. + */ + private void tickRopes(AircraftDefinition def) { + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + int clear = airBelow(50); + if (ropeLength > -50.0F && ropeLength > -clear) { + ropeLength -= level().isClientSide() ? 0.3F : 0.25F; + } + } else { + ropeLength = 0.0F; + } + } + if (level().isClientSide()) { + return; + } + if (isRepelling() && isDestroyed()) { + entityData.set(DATA_REPELLING, false); + } + if (!isRepelling()) { + repellingTicks = 0; + } else if (repellingTicks < 60) { + repellingTicks++; + } else { + for (int rope = 0; rope < def.repellingHooks.size(); rope++) { + if (ticksAlive % def.repellingHooks.get(rope).interval() != 0) { + continue; + } + for (int seat = 1; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + rappel(mob, rope); + break; + } + } + } + } + if (droppingTroops) { + if (hasBlockBelow(3, 10, false) || isDestroyed()) { + droppingTroops = false; + } else if ((hatch == null || hatch.isOn()) && ticksAlive % def.dropInterval == 0 && !dropNextTroop(def)) { + droppingTroops = false; + } + } + } + + /** Air blocks straight below, up to {@code max}. */ + private int airBelow(int max) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + int i = 0; + while (i < max && pos.getY() - i > level().getMinBuildHeight() && level().getBlockState(pos.setY(blockPosition().getY() - i - 1)).isAir()) { + i++; + } + return i; + } + + /** + * {@code canStartRepelling}: a helicopter in hover mode, level and holding still. The original + * compared against where it was a few ticks before. + */ + private boolean canStartRepelling() { + return this instanceof HelicopterEntity && isHoveringMode() && Math.abs(getXRot()) < 3.0F && Math.abs(roll()) < 3.0F + && lastTickPos != null && position().distanceTo(lastTickPos) < 0.05; + } + + /** + * The pilot's drop key, {@code unmountCrew}: with ropes, lets them down from a steady hover + * or pulls them up again; with {@code EnableParachuting}, starts or stops dropping the mobs + * aboard under parachutes; otherwise, or close to the ground, simply lets the mobs out. + * Server only. + * + * @return false if nothing could be done, so the pilot hears it + */ + public boolean dropCrew() { + AircraftDefinition def = definition(); + if (def == null || isDestroyed()) { + return false; + } + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + entityData.set(DATA_REPELLING, false); + return true; + } + if (hasBlockBelow(3, 4, false)) { + return unloadMobs(); + } + if (!canStartRepelling()) { + return false; + } + entityData.set(DATA_REPELLING, true); + repellingTicks = 0; + return true; + } + if (droppingTroops) { + droppingTroops = false; + return true; + } + if (def.parachuting && !hasBlockBelow(3, 10, false)) { + droppingTroops = true; + if (hatch != null && hatch.isOff()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } + return true; + } + return unloadMobs(); + } + + /** Lets every mob in a crew seat out, as in {@code unmountCrew(false)}. */ + private boolean unloadMobs() { + AircraftDefinition def = definition(); + boolean any = false; + for (int seat = 1; def != null && seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + mob.stopRiding(); + any = true; + } + } + return any; + } + + /** The next mob behind the second seat leaves at the drop point under a parachute. */ + private boolean dropNextTroop(AircraftDefinition def) { + for (int seat = 2; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + parachuteOut(mob, def); + return true; + } + } + return false; + } + + private void parachuteOut(Entity entity, AircraftDefinition def) { + Vec3 out = toWorld(def.dropPosition); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement()); + entity.hurtMarked = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.DROP); + } + + /** + * A passenger's own drop key, {@code MCH_ClientSeatTickHandler}: slide down a rope once the + * ropes are out, or jump with a parachute from behind the second seat of a vehicle with + * {@code EnableParachuting} and room below. Server only. + * + * @return false if neither is possible right now + */ + public boolean jumpOut(Player player) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (def == null || seat <= 0 || seat >= def.seatCount() || isDestroyed()) { + return false; + } + if (isRepelling() && repellingTicks > 50 && !def.repellingHooks.isEmpty()) { + lastRope = (lastRope + 1) % def.repellingHooks.size(); + rappel(player, lastRope); + return true; + } + if (def.parachuting && seat >= 2 && !hasBlockBelow(3, 13, false)) { + parachuteOut(player, def); + return true; + } + return false; + } + + /** {@code unmountEntityRepelling}: two blocks under the hook, onto a rope rider that lets them down. */ + private void rappel(Entity entity, int rope) { + Vec3 at = ropePosition(rope).add(0.0, -2.0, 0.0); + entity.stopRiding(); + entity.teleportTo(at.x, at.y, at.z); + entity.setDeltaMovement(Vec3.ZERO); + entity.resetFallDistance(); + RappelEntity rider = new RappelEntity(ModEntities.RAPPEL.get(), level()); + rider.attach(this, rope); + rider.setPos(at); + level().addFreshEntity(rider); + entity.startRiding(rider, true); + lastRope = rope; + } + + /** + * {@code mountMobToSeats}: when the pilot climbs in, mobs standing close by take the free crew + * seats, so villagers and the like can be flown somewhere. Server only. + */ + private void boardNearbyMobs() { + AircraftDefinition def = definition(); + if (def == null || def.uav) { + return; + } + for (Mob mob : level().getEntitiesOfClass(Mob.class, getBoundingBox().inflate(3.0, 2.0, 3.0), m -> !m.isPassenger() && !m.isVehicle())) { + for (int seat = 1; seat < def.seatCount(); seat++) { + if (isSeatFree(seat)) { + pendingSeat = seat; + mob.startRiding(this, true); + pendingSeat = EMPTY; + break; + } + } + } + } + + // 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.hull()) { + Optional hit = clip(hullBox(box).inflate(inflate), from, to); + if (hit.isPresent() && from.distanceToSqr(hit.get()) < bestDistance) { + best = hit; + bestDistance = from.distanceToSqr(hit.get()); + hitBoxFactor = box.damageFactor(); + } + } + } + return best; + } + + /** + * One {@code BoundingBox} in world space. Like the original's, it stays axis aligned: the + * centre turns with the hull, the box itself does not. + */ + private AABB hullBox(BoundingBoxDefinition box) { + Vec3 c = toWorld(box.offset()); + double hw = box.width() / 2.0; + double hh = box.height() / 2.0; + return new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw); + } + + /** The whole hull: the main hit box, every {@code BoundingBox} and the generated boxes, in world space. */ + public List hullBoxes() { + AircraftDefinition def = definition(); + if (def == null || def.hull().isEmpty()) { + return List.of(getBoundingBox()); + } + List boxes = new ArrayList<>(def.hull().size() + 1); + boxes.add(getBoundingBox()); + for (BoundingBoxDefinition box : def.hull()) { + boxes.add(hullBox(box)); + } + return boxes; + } + + /** The smallest box around the whole hull. */ + private AABB hullBounds() { + AABB bounds = getBoundingBox(); + AircraftDefinition def = definition(); + if (def != null) { + for (BoundingBoxDefinition box : def.hull()) { + bounds = bounds.minmax(hullBox(box)); + } + } + return bounds; + } + + /** Whether any part of this hull touches any part of {@code other}'s, grown by {@code inflate}. */ + private boolean hullsTouch(AircraftEntity other, double inflate) { + // Hulls generated from big models run to a thousand boxes, so skip ours that are nowhere near. + AABB around = other.hullBounds().inflate(inflate); + List theirs = null; + for (AABB mine : hullBoxes()) { + AABB grown = mine.inflate(inflate); + if (!grown.intersects(around)) { + continue; + } + if (theirs == null) { + theirs = other.hullBoxes(); + } + for (AABB box : theirs) { + if (grown.intersects(box)) { + return true; + } + } + } + return false; + } + + /** + * Vehicles whose hulls block this one: everything nearby except its own carrier or cargo, and + * vehicles either of the two could load onto a rack, which have to drive into each other. + */ + private List solidNeighbours(AABB area) { + return level().getEntitiesOfClass(AircraftEntity.class, area.inflate(HULL_REACH), other -> other != this && !other.isRemoved() + && other.definition() != null && !other.isPassenger() && !isPassengerOfSameVehicle(other) + && other.getVehicle() != this && getVehicle() != other && !canCarry(other) && !other.canCarry(this)); + } + + /** Whether a free rack of this vehicle would take {@code cargo}. */ + private boolean canCarry(AircraftEntity cargo) { + AircraftDefinition def = definition(); + if (def == null) { + return false; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (acceptsCargo(cargo, seat)) { + return true; + } + } + return false; + } + + /** + * Not in the original, where only the main box at a vehicle's feet collided: the whole hull + * blocks other vehicles. A move that would push this hull into another vehicle's is undone, + * sideways first and then completely, so a helicopter can also set down on a parked truck. + * Hulls that already overlapped, say after two vehicles were placed into each other, may move + * apart. + */ + @Override + public void move(MoverType type, Vec3 delta) { + if (type != MoverType.SELF || isPassenger() || definition() == null) { + super.move(type, delta); + return; + } + List near = solidNeighbours(hullBounds().expandTowards(delta)); + if (near.isEmpty()) { + super.move(type, delta); + return; + } + List overlapping = new ArrayList<>(); + for (AircraftEntity other : near) { + if (hullsTouch(other, 0.0)) { + overlapping.add(other); + } + } + Vec3 before = position(); + super.move(type, delta); + if (!entersHull(near, overlapping)) { + return; + } + Vec3 m = getDeltaMovement(); + setPos(before.x, getY(), before.z); + setDeltaMovement(0.0, m.y, 0.0); + if (entersHull(near, overlapping)) { + setPos(before); + setDeltaMovement(Vec3.ZERO); + } + } + + private boolean entersHull(List near, List overlapping) { + for (AircraftEntity other : near) { + if (!overlapping.contains(other) && hullsTouch(other, 0.0)) { + return true; + } + } + return false; + } + + /** 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.20.6 also calls this on the attacking client; only the server decides. + if (!(level() instanceof ServerLevel level)) { + return false; + } + float boxFactor = consumeHitBoxFactor(); + if (testFlightStart != null || 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() || isHeavy() && isNearGround(3)) { + 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; + 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; + } + if (isHeavy()) { + startCookOff(null); + return; + } + exploded = true; + AircraftDefinition def = definition(); + float size = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + Vec3 at = def != null && def.fuelTankPosition != null ? toWorld(def.fuelTankPosition) : position(); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, null, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + if (!isInWater()) { + Explosions.crashFireball(level, at.x, at.y, at.z, size); + } + } + + /** + * Airliners, bombers and transports ({@code MaxFuel} of {@value HEAVY_FUEL} or more): like a + * boat, they go up in three blasts rather than one fireball. + */ + private boolean isHeavy() { + AircraftDefinition def = definition(); + return def != null && def.maxFuel >= HEAVY_FUEL; + } + + /** 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, or a heavy aircraft wrecked on the ground, does not go up in + * one crash fireball; its fuel and magazine cook off in three blasts over about five seconds, + * then a hull in the water 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 { + // A boat or a flying boat goes under; a heavy aircraft on land stays where it burns. + if (isInWater() || waterDepth() > 0.0) { + 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; + crashSpin(); + 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); + } + } + } + if (despawnCount > 0 && --despawnCount <= 1) { + ejectPassengers(); + discard(); + } + } + + /** + * Not in the original: a shot down plane rolls into a nose-down spiral and a helicopter spins + * with some wobble, until it is close to the ground. Tanks and cars just stop. + */ + private void crashSpin() { + if (!(this instanceof HelicopterEntity) && !(this instanceof PlaneEntity) || isNearGround(3) || isInWater()) { + return; + } + int direction = CrashSpin.direction(getId()); + int ticks = spinTicks++; + if (this instanceof HelicopterEntity) { + setYRot(getYRot() + CrashSpin.helicopterYawRate(ticks, direction)); + setXRot(Mth.approach(getXRot(), CrashSpin.helicopterWobblePitch(ticks, getId()), 2.0F)); + roll = Mth.approach(roll, CrashSpin.helicopterWobbleRoll(ticks, getId()), 3.0F); + } else { + setYRot(getYRot() + CrashSpin.planeYawRate(ticks, direction)); + setXRot(CrashSpin.planePitch(getXRot())); + roll = AircraftRotation.wrap(roll + CrashSpin.planeRollRate(ticks, direction)); + } + } + + /** {@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 = def.enginePosition != null ? toWorld(def.enginePosition) : 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.20.6. + */ + @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--; + } + } + + /** A vehicle that is destroyed, packed up or killed spills what it carried; one that unloads keeps it. */ + @Override + public void remove(RemovalReason reason) { + if (reason.shouldDestroy() && !level().isClientSide() && !isRemoved()) { + inventory.dropAll(); + } + super.remove(reason); + } + + @Override + protected void addAdditionalSaveData(CompoundTag tag) { + tag.putString("Type", typeName()); + tag.putString("Texture", texture()); + tag.putInt("Hp", hp()); + tag.putInt("Fuel", fuel()); + 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)); + tag.putBoolean("SearchLight", entityData.get(DATA_SEARCH_LIGHT)); + if (weapons != null) { + tag.putIntArray("WeaponsAmmo", weapons.saveAmmo()); + } + inventory.save(tag); + if (station != null && stationReturn != null) { + tag.putUUID("Station", station); + tag.putDouble("StationReturnX", stationReturn.x); + tag.putDouble("StationReturnY", stationReturn.y); + tag.putDouble("StationReturnZ", stationReturn.z); + } + 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); + // 1.0.0 and the earlier betas had no fuel; their vehicles come back with a full tank. + entityData.set(DATA_FUEL, tag.contains("Fuel") ? tag.getInt("Fuel") : def != null ? def.maxFuel : 0); + 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")); + entityData.set(DATA_SEARCH_LIGHT, tag.getBoolean("SearchLight")); + 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; + } + } + inventory.load(tag); + station = tag.hasUUID("Station") ? tag.getUUID("Station") : null; + stationReturn = station != null ? new Vec3(tag.getDouble("StationReturnX"), tag.getDouble("StationReturnY"), tag.getDouble("StationReturnZ")) : null; + 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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java new file mode 100644 index 00000000..eda5ed7b --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java new file mode 100644 index 00000000..205e250c --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java @@ -0,0 +1,191 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +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.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 net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * A tow chain, {@code MCH_EntityChain}: it hangs above the vehicle that pulls and drags the towed + * one along, letting it drift up to the chain's length away and never more than two blocks past + * that. It goes when either end does. + */ +public class ChainEntity extends Entity { + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 15; + /** Ticks a chain loaded from disk waits for both ends to load before it gives up. */ + private static final int SEARCH_TICKS = 50; + + private static final EntityDataAccessor DATA_TOW = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TOWED = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + + private @Nullable UUID towId; + private @Nullable UUID towedId; + private @Nullable Entity tow; + private @Nullable Entity towed; + private int length = 4; + private int searchTicks = SEARCH_TICKS; + + public ChainEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + /** Chains {@code towed} to {@code tow}, at the distance they are apart now. Server only. */ + public static ChainEntity connect(Level level, Entity towed, Entity tow) { + ChainEntity chain = new ChainEntity(ModEntities.CHAIN.get(), level); + chain.setPos(tow.position().add(towed.position()).scale(0.5)); + chain.length = Mth.clamp((int) towed.distanceTo(tow), MIN_LENGTH, MAX_LENGTH); + chain.attach(towed, tow); + level.addFreshEntity(chain); + ModSounds.playAt(chain.tow, "chain_ct", 1.0F, 1.0F); + return chain; + } + + /** Whether {@code entity} can pull the other end: only vehicles can. */ + public static boolean canTow(Entity entity) { + return entity instanceof AircraftEntity; + } + + /** + * Only a vehicle pulls. Hooked the other way round, a container would hold a helicopter down, + * so the ends swap whichever order they were clicked in (and chains saved that way are fixed + * when they load). + */ + private void attach(Entity towed, Entity tow) { + if (!canTow(tow) && canTow(towed)) { + Entity swap = tow; + tow = towed; + towed = swap; + } + this.towed = towed; + this.tow = tow; + this.towedId = towed.getUUID(); + this.towId = tow.getUUID(); + entityData.set(DATA_TOWED, towed.getId()); + entityData.set(DATA_TOW, tow.getId()); + searchTicks = 0; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_TOW, -1); + builder.define(DATA_TOWED, -1); + } + + /** The end that pulls; on a client, as far as it has loaded. */ + public @Nullable Entity tow() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOW)) : tow; + } + + /** The end that is pulled. */ + public @Nullable Entity towed() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOWED)) : towed; + } + + /** Whether {@code entity} is either end of this chain. */ + public boolean holds(Entity entity) { + return entity == tow() || entity == towed(); + } + + /** Takes the chain off, with the sound of it coming loose. Server only. */ + public void disconnect() { + ModSounds.playAt(this, "chain_ct", 1.0F, 1.0F); + discard(); + } + + /** The chain on {@code entity}, at either end, if there is one within reach. */ + public static @Nullable ChainEntity on(Entity entity) { + for (ChainEntity chain : entity.level().getEntitiesOfClass(ChainEntity.class, entity.getBoundingBox().inflate(25.0))) { + if (chain.holds(entity)) { + return chain; + } + } + return null; + } + + @Override + public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel server)) { + return; + } + if (searchTicks > 0) { + searchTicks--; + Entity foundTowed = towedId != null ? server.getEntity(towedId) : null; + Entity foundTow = towId != null ? server.getEntity(towId) : null; + if (foundTowed != null && foundTow != null) { + attach(foundTowed, foundTow); + } + return; + } + if (tow == null || towed == null || tow.isRemoved() || towed.isRemoved() || tow == towed) { + disconnect(); + return; + } + towed.resetFallDistance(); + setPos(tow.getX(), tow.getY() + 2.0, tow.getZ()); + pull(tow, towed); + } + + /** {@code updateTowingEntityPosRot}: turns the towed end to face along the chain and drags it in. */ + private void pull(Entity tow, Entity towed) { + Vec3 d = towed.position().subtract(tow.position()); + double dist = d.length(); + if (dist <= length) { + return; + } + towed.setYRot((float) Math.toDegrees(Math.atan2(d.z, d.x)) + 90.0F); + double slack = dist - length; + towed.setDeltaMovement(towed.getDeltaMovement().subtract(d.scale(0.001 / slack))); + double max = length + 2.0; + if (dist > max) { + Vec3 at = tow.position().add(d.scale(max / dist)); + towed.setPos(at.x, at.y, at.z); + } + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + towId = input.hasUUID("Tow") ? input.getUUID("Tow") : null; + towedId = input.hasUUID("Towed") ? input.getUUID("Towed") : null; + length = Mth.clamp((input.contains("Length") ? input.getInt("Length") : 4), MIN_LENGTH, MAX_LENGTH); + searchTicks = SEARCH_TICKS; + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + if (towId != null && towedId != null) { + output.putUUID("Tow", towId); + output.putUUID("Towed", towedId); + } + output.putInt("Length", length); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java new file mode 100644 index 00000000..28f14851 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +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) { + } + + @Override + public void onPackSound(PackSoundPacket 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); + + void onPackSound(PackSoundPacket packet); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java new file mode 100644 index 00000000..3c4ebc5b --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.network.chat.Component; +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.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A cargo container, {@code MCH_EntityContainer}: a crate with a large chest's worth of room that + * sits on the ground, floats, can be pushed around, and rides the racks of the vehicles that carry + * {@code container}. Dropped from a rack high enough it comes down under a parachute. + */ +public class ContainerEntity extends Entity { + /** What racks call it in their {@code AddRack} lines. */ + public static final String CARGO_NAME = "container"; + private static final int SLOTS = 54; + /** Punches add up; the original broke it past 40 with 20 per point of damage. */ + private static final int BREAK_DAMAGE = 40; + + private final SimpleContainer items = new SimpleContainer(SLOTS); + private int damageTaken; + + public ContainerEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + @Override + public void tick() { + super.tick(); + if (level().isClientSide() || isPassenger()) { + return; + } + if (damageTaken > 0) { + damageTaken--; + } + Vec3 m = getDeltaMovement(); + double my = m.y; + if (isInWater() || isInLava()) { + // Bobs up to the surface rather than sinking, as the original's boat physics did. + my = (my < 0.0 ? my / 2.0 : my) + (isEyeInFluid(FluidTags.WATER) ? 0.04 : 0.007); + } else { + my -= 0.04; + } + double mx = m.x; + double mz = m.z; + if (onGround()) { + mx *= 0.9; + mz *= 0.9; + } + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand) { + if (player.getItemInHand(hand).getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (player.isSecondaryUseActive()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider((id, inventory, p) -> ChestMenu.sixRows(id, inventory, items), + Component.translatable("entity.mcheli.container"))); + } + return InteractionResult.SUCCESS; + } + + @Override + public boolean hurt(DamageSource source, float damage) { + if (!(level() instanceof ServerLevel level)) { + return false; + } + if (isInvulnerableTo(source) || isRemoved() || !(source.getEntity() instanceof Player player)) { + // Only players take it apart; explosions and stray rounds leave the cargo alone. + return false; + } + ModSounds.playAt(this, "hit", 1.0F, 1.3F); + damageTaken += (int) (damage * 20.0F); + boolean creative = player.getAbilities().instabuild; + if (creative || damageTaken > BREAK_DAMAGE) { + if (!creative) { + spawnAtLocation(ModItems.CONTAINER.get()); + } + Containers.dropContents(level, this, items); + discard(); + } + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean isPushable() { + return !isPassenger(); + } + + @Override + public boolean canBeCollidedWith() { + return !isPassenger(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.CONTAINER.get()); + } + @Override + protected void readAdditionalSaveData(CompoundTag input) { + items.clearContent(); + ContainerHelper.loadAllItems(input, items.getItems(), registryAccess()); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + ContainerHelper.saveAllItems(output, items.getItems(), false, registryAccess()); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java new file mode 100644 index 00000000..6b25a388 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java new file mode 100644 index 00000000..0f83cacd --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java @@ -0,0 +1,206 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.weapon.CasStrike; +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.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The ground laser target designator, {@code MCH_EntityGLTD}: a tripod a player sits behind. Its + * sight looks up to 70 degrees either side of where it was set up, zooms and has night vision, + * and marking a spot calls in an A-10 for three gun runs over it. + */ +public class GltdEntity extends Entity { + /** How far the sight turns from the tripod's heading, either way, and up or down. */ + public static final float VIEW_LIMIT = 70.0F; + /** The CAS weapon the A-10s fire; the original hard coded it too. */ + private static final String WEAPON = "a10gau8"; + /** Added to the weapon's own {@code Delay} between two calls. */ + private static final int EXTRA_WAIT = 150; + private static final double SIGHT_RANGE = 300.0; + + private static final EntityDataAccessor DATA_WAIT = SynchedEntityData.defineId(GltdEntity.class, EntityDataSerializers.INT); + + private final CasStrike strike = new CasStrike(WEAPON); + private boolean nightVision; + + public GltdEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WAIT, 0); + } + + /** Ticks until the next A-10 can be called; the sight shows it. */ + public int waitTicks() { + return entityData.get(DATA_WAIT); + } + + /** The eye of the sight, a little in front of and above the tripod. */ + public Vec3 sightPosition(float partialTicks) { + float yaw = getViewYRot(partialTicks) * Mth.DEG_TO_RAD; + Vec3 pos = getPosition(partialTicks); + return new Vec3(pos.x - Mth.sin(yaw) * 0.6, pos.y + 0.7, pos.z + Mth.cos(yaw) * 0.6); + } + + @Override + public void tick() { + super.tick(); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.04, 0.0); + if (onGround()) { + m = m.scale(0.5); + } + move(MoverType.SELF, m); + setDeltaMovement(m.x * 0.99, m.y * 0.95, m.z * 0.99); + + if (waitTicks() > 0) { + entityData.set(DATA_WAIT, waitTicks() - 1); + } + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (def != null && level() instanceof ServerLevel server) { + strike.tick(server, def); + } + Entity viewer = getFirstPassenger(); + if (viewer == null) { + nightVision = false; + } else if (nightVision) { + NightVision.topUp(viewer); + } + } + + /** + * {@code useCurrentWeapon}: marks the block {@code player} looks at through the sight and calls + * the A-10 on it. Server only. + * + * @return whether the strike was called; the sight may be waiting, or see nothing in range + */ + public boolean callStrike(ServerPlayer player) { + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (getFirstPassenger() != player || waitTicks() > 0 || def == null || !(level() instanceof ServerLevel server)) { + return false; + } + Vec3 from = sightPosition(1.0F); + Vec3 to = from.add(player.getLookAngle().scale(SIGHT_RANGE)); + BlockHitResult hit = server.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + strike.start(hit.getLocation(), player.getYRot(), this, player); + entityData.set(DATA_WAIT, def.delay + EXTRA_WAIT); + ModSounds.playAt(server, getX(), getY(), getZ(), "gltd", 0.5F, 1.0F); + if (def.sound != null) { + ModSounds.playTo(player, def.sound, 1.0F, 1.0F); + } + return true; + } + + public void setNightVision(Player player, boolean on) { + if (getFirstPassenger() != player) { + return; + } + nightVision = on; + if (!on) { + NightVision.end(player); + } + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand) { + if (player.isSecondaryUseActive() || isVehicle()) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + player.setYRot(getYRot()); + player.setXRot(0.0F); + player.startRiding(this); + } + return InteractionResult.SUCCESS; + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (!level().isClientSide() && nightVision) { + NightVision.end(passenger); + nightVision = false; + } + } + + /** The viewer sits behind the tripod. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, -0.5).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + /** The original clamped the viewer's head the same way every tick. */ + @Override + public void onPassengerTurned(Entity passenger) { + float yaw = getYRot() + Mth.clamp(Mth.wrapDegrees(passenger.getYRot() - getYRot()), -VIEW_LIMIT, VIEW_LIMIT); + passenger.setYRot(yaw); + passenger.setYHeadRot(yaw); + passenger.setXRot(Mth.clamp(passenger.getXRot(), -VIEW_LIMIT, VIEW_LIMIT)); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + if (!(level() instanceof ServerLevel level)) { + return false; + } + if (isInvulnerableTo(source) || isRemoved()) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + if (!creative) { + spawnAtLocation(ModItems.GLTD.get()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), "hit", 1.0F, 1.0F); + discard(); + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.GLTD.get()); + } + @Override + protected void readAdditionalSaveData(CompoundTag input) { + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java new file mode 100644 index 00000000..30290141 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java new file mode 100644 index 00000000..96758a2c --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -0,0 +1,552 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +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.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +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.
  • + *
  • Blades fold and unfold one after another at the same pace on both sides, from the + * synced fold state, instead of each client stepping its own copy.
  • + *
+ */ +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 static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + + private double currentSpeed = MIN_SPEED; + private float rotorAngle; + public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; + + 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; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, (input.contains("FoldBlade") ? input.getBoolean("FoldBlade") : false) ? FOLDED : UNFOLDED); + } + + /** 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() && canUseBlades()) { + setHovering(true); + } + } + + private boolean isLevelEnough() { + return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; + } + + @Override + public boolean canSwitchGunnerMode() { + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); + } + + @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; + } + // Standing on its skids or wheels a helicopter stays level; the mouse only tilts it in the air. + if (!isHovering() && !onGround()) { + 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 (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } + 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; + tickBlades(def); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 00000000..f52c4227 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 00000000..084f860b --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java @@ -0,0 +1,179 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A parachute with someone hanging under it, {@code MCH_EntityParachute}. It sinks at about two + * blocks a second, the rider steers it with the forward key, and it folds away a moment after it + * touches the ground or the water. + */ +public class ParachuteEntity extends Entity { + /** What the parachute looks like and how it opens, the original's model type. */ + public enum Kind { + /** From the parachute item: open from the start. */ + ITEM, + /** An ejection seat: the rider falls in the seat and the canopy opens once it drops. */ + EJECTION_SEAT, + /** Troops dropped from a rack or a cargo door: open from the start, no seat. */ + DROP + } + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(ParachuteEntity.class, EntityDataSerializers.INT); + private static final double MAX_SPEED = 0.35; + private static final double MIN_STEER = 0.07; + + private double steer = MIN_STEER; + private int onGroundCount; + /** Who boards on the next server tick; boarding in the same tick as the spawn can be lost. */ + private @Nullable Entity user; + + public ParachuteEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Opens a parachute above {@code user}, carrying over their speed, and puts them under it. + * Server only. + */ + public static ParachuteEntity open(Level level, Entity user, Kind kind) { + ParachuteEntity parachute = new ParachuteEntity(ModEntities.PARACHUTE.get(), level); + double lift = kind == Kind.ITEM ? 3.5 : 0.0; + parachute.setPos(user.getX(), user.getY() + lift, user.getZ()); + parachute.setYRot(user.getYRot()); + parachute.setDeltaMovement(user.getDeltaMovement()); + parachute.entityData.set(DATA_KIND, kind.ordinal()); + parachute.user = user; + user.resetFallDistance(); + level.addFreshEntity(parachute); + return parachute; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, Kind.ITEM.ordinal()); + } + + public Kind kind() { + Kind[] kinds = Kind.values(); + int i = entityData.get(DATA_KIND); + return i >= 0 && i < kinds.length ? kinds[i] : Kind.ITEM; + } + + /** An ejection seat only opens its canopy once it has started to fall. */ + public boolean isOpen() { + return kind() != Kind.EJECTION_SEAT || getDeltaMovement().y < -0.1; + } + + @Override + public void tick() { + super.tick(); + if (level().isClientSide()) { + return; + } + if (user != null) { + if (!user.isPassenger() && user.isAlive()) { + user.startRiding(this, true); + setYRot(user.getYRot()); + } + user = null; + } + Entity rider = getFirstPassenger(); + if (rider == null && tickCount > 20 || isInWater()) { + ejectPassengers(); + discard(); + return; + } + if (rider != null) { + rider.resetFallDistance(); + } + simulate(rider); + } + + /** {@code onUpdateServer}. */ + private void simulate(@Nullable Entity rider) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevSpeed = Math.sqrt(mx * mx + mz * mz); + boolean seatLaunch = kind() == Kind.EJECTION_SEAT && tickCount < 20; + my -= onGround() || seatLaunch ? 0.01 : 0.03; + + if (isOpen()) { + if (rider instanceof ServerPlayer player && player.zza > 0.0F) { + double yaw = Math.toRadians(rider.getYRot()); + mx += -Math.sin(yaw) * steer * 0.05; + mz += Math.cos(yaw) * steer * 0.05; + } + double speed = Math.sqrt(mx * mx + mz * mz); + if (speed > MAX_SPEED) { + mx *= MAX_SPEED / speed; + mz *= MAX_SPEED / speed; + speed = MAX_SPEED; + } + // Holding course builds up speed; letting go settles back to a slow drift. + if (speed > prevSpeed) { + steer = Math.min(steer + (MAX_SPEED - steer) / 35.0, MAX_SPEED); + } else { + steer = Math.max(steer - (steer - MIN_STEER) / 35.0, MIN_STEER); + } + } + + if (onGround() && ++onGroundCount > 5) { + ejectPassengers(); + discard(); + return; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + my *= seatLaunch ? 0.95 : 0.9; + double drag = isOpen() ? 0.95 : 0.99; + setDeltaMovement(mx * drag, my, mz * drag); + if (rider != null) { + setYRot(rider.getYRot()); + } + } + + /** + * The server moves the parachute; clients ease towards each position it sends over three + * ticks, as for the vehicles. Without this a slow descent steps from one update to the next. + */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + entityData.set(DATA_KIND, (input.contains("Kind") ? input.getInt("Kind") : Kind.ITEM.ordinal())); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putInt("Kind", kind().ordinal()); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/PartState.java new file mode 100644 index 00000000..54755d56 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java new file mode 100644 index 00000000..b80171b8 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 00000000..3023c3ae --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,96 @@ +package com.jnhprojects.mcheli.entity; + +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + @Override + protected void readAdditionalSaveData(CompoundTag input) { + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java new file mode 100644 index 00000000..e729c7f5 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java new file mode 100644 index 00000000..92e8e59e --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 00000000..52da80bc --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,330 @@ +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.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +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.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

The original kept the player at the station and had the drone read its pilot from there. + * Here the pilot takes the drone's reserved seat for as long as they fly it, hidden and unhurt, + * so the drone uses the same controls, cameras, HUD and weapons as every other vehicle. Leaving + * the drone, or losing it, puts the pilot back beside the station. + */ +public class UavStationEntity extends Entity { + public static final int STATION = 1; + public static final int PORTABLE = 2; + /** Further out than this, measured flat, the link breaks and the drone is lost, as in the original. */ + public static final double RANGE = 123.0; + /** The launch offset goes up to this far from the station along each axis. */ + public static final int MAX_OFFSET = 50; + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_OPEN = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_OFFSET = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BLOCK_POS); + private static final EntityDataAccessor DATA_LINKED = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + + private final SimpleContainer slot = new SimpleContainer(1); + private @Nullable UUID lastUav; + /** The lid of the portable controller, 0 shut to 1 open. */ + private float cover; + private float previousCover; + + public UavStationEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, (byte) STATION); + builder.define(DATA_OPEN, true); + builder.define(DATA_OFFSET, new BlockPos(12, 2, 12)); + builder.define(DATA_LINKED, false); + } + + public int kind() { + return entityData.get(DATA_KIND); + } + + public void setKind(int kind) { + entityData.set(DATA_KIND, (byte) (kind == PORTABLE ? PORTABLE : STATION)); + } + + public boolean isOpen() { + return entityData.get(DATA_OPEN); + } + + /** Where a launched drone appears, relative to the station. */ + public BlockPos offset() { + return entityData.get(DATA_OFFSET); + } + + public void setOffset(BlockPos offset) { + entityData.set(DATA_OFFSET, new BlockPos(Mth.clamp(offset.getX(), -MAX_OFFSET, MAX_OFFSET), + Mth.clamp(offset.getY(), -MAX_OFFSET, MAX_OFFSET), Mth.clamp(offset.getZ(), -MAX_OFFSET, MAX_OFFSET))); + } + + /** Whether a drone is flown from here right now; the screen lights up. */ + public boolean isLinked() { + return entityData.get(DATA_LINKED); + } + + public void setLinked(boolean linked) { + entityData.set(DATA_LINKED, linked); + } + + public SimpleContainer slot() { + return slot; + } + + public float cover(float partialTicks) { + return Mth.lerp(partialTicks, previousCover, cover); + } + + /** + * {@code initUavPostion}: the first launch offset is twelve blocks out on the diagonal the + * station faces, two up. + */ + public void initOffset() { + int quarter = (int) (Mth.positiveModulo(getYRot() + 45.0F, 360.0F) / 90.0F) % 4; + int x = quarter == 0 || quarter == 3 ? 12 : -12; + int z = quarter == 0 || quarter == 1 ? 12 : -12; + setOffset(new BlockPos(x, 2, z)); + } + + @Override + public void tick() { + super.tick(); + previousCover = cover; + cover = Mth.clamp(cover + (isOpen() ? 0.1F : -0.1F), 0.0F, 1.0F); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.03, 0.0); + move(MoverType.SELF, new Vec3(0.0, m.y, 0.0)); + setDeltaMovement(0.0, onGround() ? 0.0 : m.y * 0.96, 0.0); + + Entity user = getFirstPassenger(); + if (user instanceof ServerPlayer player && !slot.getItem(0).isEmpty()) { + launch(player, slot.getItem(0)); + } + } + + /** What the slot holds, if it is a vehicle at all. */ + public static @Nullable AircraftDefinition definitionOf(ItemStack stack) { + String name = AircraftItem.vehicleOf(stack); + return name != null ? MCHeli.content().any(name) : null; + } + + /** Whether this station can put up the vehicle in {@code stack}. */ + public boolean accepts(@Nullable AircraftDefinition def) { + return def != null && def.uav && (kind() != PORTABLE || def.smallUav); + } + + /** {@code handleItem}: puts up the drone in the slot and hands it to {@code player}. */ + private void launch(ServerPlayer player, ItemStack stack) { + AircraftDefinition def = definitionOf(stack); + if (!accepts(def) || !(level() instanceof ServerLevel server)) { + return; + } + if (ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return; + } + AircraftEntity uav = ModEntities.typeFor(def).create(server); + if (uav == null) { + return; + } + BlockPos o = offset(); + double y = Math.max(getY() + o.getY(), server.getMinBuildHeight() + 2.0); + uav.setType(def); + uav.moveTo(getX() + o.getX(), y, getZ() + o.getZ(), getYRot() - 180.0F, 0.0F); + if (!server.noCollision(uav, uav.getBoundingBox().deflate(0.1))) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.blocked"), true); + slot.setItem(0, ItemStack.EMPTY); + Containers.dropItemStack(server, player.getX(), player.getY(), player.getZ(), stack); + return; + } + slot.setItem(0, ItemStack.EMPTY); + uav.setFuelLevel(def.targetDrone ? uav.maxFuel() : (int) (uav.maxFuel() * 0.05F)); + server.addFreshEntity(uav); + if (!def.targetDrone) { + player.closeContainer(); + control(player, uav); + } + } + + /** {@code controlLastAircraft}: takes the last drone flown from here again. */ + public boolean continueControl(ServerPlayer player) { + AircraftEntity uav = lastUav(); + if (uav == null || getFirstPassenger() != player || !uav.canTakeRemotePilot()) { + return false; + } + player.closeContainer(); + return control(player, uav); + } + + public @Nullable AircraftEntity lastUav() { + if (lastUav == null || !(level() instanceof ServerLevel server)) { + return null; + } + Entity e = server.getEntity(lastUav); + return e instanceof AircraftEntity uav && uav.isAlive() && !uav.isDestroyed() && inRange(uav) ? uav : null; + } + + public boolean inRange(Entity uav) { + double dx = uav.getX() - getX(); + double dz = uav.getZ() - getZ(); + return dx * dx + dz * dz <= RANGE * RANGE; + } + + private boolean control(ServerPlayer player, AircraftEntity uav) { + player.stopRiding(); + if (!uav.takeRemotePilot(player, this)) { + return false; + } + lastUav = uav.getUUID(); + setLinked(true); + player.setYRot(uav.getYRot()); + return true; + } + + /** Where a pilot comes back to when the drone is left or lost: the seat in front of the station. */ + public Vec3 returnPosition() { + float yaw = getYRot() * Mth.DEG_TO_RAD; + return new Vec3(getX() - Mth.sin(yaw) * 0.9, getY(), getZ() + Mth.cos(yaw) * 0.9); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand) { + if (isVehicle()) { + return InteractionResult.PASS; + } + if (kind() == PORTABLE && player.isSecondaryUseActive()) { + if (!level().isClientSide()) { + entityData.set(DATA_OPEN, !isOpen()); + } + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive() || kind() == PORTABLE && !isOpen()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer server) { + player.setYRot(getYRot() - 180.0F); + if (player.startRiding(this)) { + server.openMenu(new SimpleMenuProvider((id, inventory, p) -> new UavStationMenu(id, inventory, this), + Component.translatable(kind() == PORTABLE ? "item.mcheli.uav_station2" : "item.mcheli.uav_station"))); + } + } + return InteractionResult.SUCCESS; + } + + /** The original sat the player down in front of the console, lower for the portable one. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + double y = kind() == PORTABLE ? -0.6 : 0.35; + return new Vec3(0.0, y, 0.9).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + if (!(level() instanceof ServerLevel level)) { + return false; + } + if (isInvulnerableTo(source) || isRemoved() || damage <= 0.0F) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + AircraftEntity uav = lastUav(); + if (uav != null) { + uav.releaseRemotePilot(); + } + Containers.dropContents(level, blockPosition(), slot); + if (!creative) { + spawnAtLocation(item()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), source.getEntity() instanceof Player ? "hit" : "helidmg", 1.0F, 1.0F); + discard(); + return true; + } + + private Item item() { + return kind() == PORTABLE ? ModItems.UAV_CONTROLLER.get() : ModItems.UAV_STATION.get(); + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith() { + return true; + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(item()); + } + @Override + protected void readAdditionalSaveData(CompoundTag input) { + setKind((input.contains("Kind") ? input.getInt("Kind") : STATION)); + entityData.set(DATA_OPEN, (input.contains("Open") ? input.getBoolean("Open") : true)); + setOffset(new BlockPos((input.contains("UavX") ? input.getInt("UavX") : 12), (input.contains("UavY") ? input.getInt("UavY") : 2), (input.contains("UavZ") ? input.getInt("UavZ") : 12))); + lastUav = input.hasUUID("LastUav") ? input.getUUID("LastUav") : null; + slot.setItem(0, ItemStack.parseOptional(registryAccess(), input.getCompound("Item"))); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putInt("Kind", kind()); + output.putBoolean("Open", isOpen()); + BlockPos o = offset(); + output.putInt("UavX", o.getX()); + output.putInt("UavY", o.getY()); + output.putInt("UavZ", o.getZ()); + if (lastUav != null) { + output.putUUID("LastUav", lastUav); + } + if (!slot.getItem(0).isEmpty()) { + output.put("Item", slot.getItem(0).save(registryAccess())); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java new file mode 100644 index 00000000..5ea8dcd2 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java @@ -0,0 +1,144 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.supply.SupplyRules; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.nbt.CompoundTag; + +/** + * What a vehicle carries besides its crew, {@code MCH_AircraftInventory} and the container half of + * {@code W_EntityContainer}: the equipment slots of the vehicle screen (three fuel cans and a + * parachute per ejection seat) and the cargo chest of vehicles with an {@code InventorySize}. + * + *

The cargo chest always has room for the largest size, 54 slots, and the screen shows as many + * rows as the definition asks for. Items in rows a content reload took away are kept rather than + * lost, and still drop when the vehicle is destroyed. + */ +public final class VehicleInventory { + public static final int FUEL_SLOTS = 3; + public static final int PARACHUTE_SLOTS = 2; + public static final int EQUIPMENT_SLOTS = FUEL_SLOTS + PARACHUTE_SLOTS; + public static final int CARGO_SLOTS = 54; + /** How far from the vehicle's centre a player outside may keep a screen open, past its width. */ + private static final double REACH = 12.0; + + private final AircraftEntity vehicle; + private final SimpleContainer equipment = new GuardedContainer(EQUIPMENT_SLOTS); + private final SimpleContainer cargo = new GuardedContainer(CARGO_SLOTS); + + VehicleInventory(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + public SimpleContainer equipment() { + return equipment; + } + + public SimpleContainer cargo() { + return cargo; + } + + /** {@code isUseableByPlayer}: the crew, or someone standing next to the vehicle. */ + public boolean stillValid(Player player) { + if (vehicle.isRemoved() || vehicle.definition() == null) { + return false; + } + if (player.getVehicle() == vehicle) { + return true; + } + double reach = REACH + vehicle.definition().bodyWidth / 2.0; + return player.distanceToSqr(vehicle) <= reach * reach; + } + + // Fuel + + /** + * {@code updateFuel}: every half second, while the vehicle stands on something, each fuel can + * in a fuel slot pours up to 100 units into the tank. Server only. + * + * @return whether any fuel went in + */ + boolean pourFuelCans(int ticks) { + if (ticks % SupplyRules.FUEL_SLOT_INTERVAL != 0 || !vehicle.canSupply()) { + return false; + } + boolean poured = false; + for (int slot = 0; slot < FUEL_SLOTS; slot++) { + ItemStack can = equipment.getItem(slot); + if (!(can.getItem() instanceof FuelItem)) { + continue; + } + int amount = SupplyRules.fuelFromCan(vehicle.maxFuel() - vehicle.fuel(), FuelItem.contents(can)); + if (amount > 0) { + vehicle.refuel(amount); + can.setDamageValue(can.getDamageValue() + amount); + poured = true; + } + } + if (poured) { + equipment.setChanged(); + } + return poured; + } + + // Parachutes + + /** Parachute slots the vehicle screen offers: one per ejection seat. */ + public static int parachuteSlots(AircraftDefinition def) { + return Math.min(def.ejectionSeats(), PARACHUTE_SLOTS); + } + + /** + * {@code consumeParachute}: takes one parachute out of the parachute slots for a crew member + * who bails out. + * + * @return false when there is none left + */ + boolean takeParachute() { + for (int slot = FUEL_SLOTS; slot < EQUIPMENT_SLOTS; slot++) { + if (equipment.getItem(slot).getItem() instanceof ParachuteItem) { + equipment.removeItem(slot, 1); + return true; + } + } + return false; + } + + // Saving and dropping + + void save(CompoundTag output) { + output.put("Equipment", ContainerHelper.saveAllItems(new CompoundTag(), equipment.getItems(), false, vehicle.registryAccess())); + output.put("Cargo", ContainerHelper.saveAllItems(new CompoundTag(), cargo.getItems(), false, vehicle.registryAccess())); + } + + void load(CompoundTag input) { + equipment.clearContent(); + cargo.clearContent(); + ContainerHelper.loadAllItems(input.getCompound("Equipment"), equipment.getItems(), vehicle.registryAccess()); + ContainerHelper.loadAllItems(input.getCompound("Cargo"), cargo.getItems(), vehicle.registryAccess()); + } + + /** {@code setDead}: everything aboard falls out where the vehicle was. Server only. */ + void dropAll() { + Containers.dropContents(vehicle.level(), vehicle, equipment); + Containers.dropContents(vehicle.level(), vehicle, cargo); + } + + /** A container that only stays open for players {@link #stillValid(Player)} allows. */ + private final class GuardedContainer extends SimpleContainer { + GuardedContainer(int size) { + super(size); + } + + @Override + public boolean stillValid(Player player) { + return VehicleInventory.this.stillValid(player); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java new file mode 100644 index 00000000..e441d2bd --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.ParticleSplash; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * The spray, dust and exhaust a vehicle throws up, all client side: + *

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.level().getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.level().getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.level().getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.level().getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.level().getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java new file mode 100644 index 00000000..926c6783 --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java new file mode 100644 index 00000000..a9d41520 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java new file mode 100644 index 00000000..e5f46c96 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java new file mode 100644 index 00000000..d7031ecf --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java new file mode 100644 index 00000000..c758d75b --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java new file mode 100644 index 00000000..b2b418ac --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java new file mode 100644 index 00000000..2fe1f43a --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java new file mode 100644 index 00000000..c8a9905f --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java new file mode 100644 index 00000000..7ccac6f0 --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java new file mode 100644 index 00000000..a572358b --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -0,0 +1,799 @@ +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); + /** Who fired, for clients: their rounds must not stop on their own vehicle. */ + private static final EntityDataAccessor DATA_SHOOTER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_USER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + + /** 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; + /** Client only: the round hit something here and waits, hidden, for the server to remove it. */ + private boolean spent; + + 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); + builder.define(DATA_SHOOTER, 0); + builder.define(DATA_USER, 0); + } + + /** + * 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; + setOwners(shooter, 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; + setOwners(parent.shooter, parent.user); + } + + public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + entityData.set(DATA_SHOOTER, shooter != null ? shooter.getId() : 0); + entityData.set(DATA_USER, user != null ? user.getId() : 0); + } + + 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 (spent) { + return; + } + 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; + } + + /** Clients only learn who fired through synched data; look them up once it has arrived. */ + private void resolveOwners() { + if (shooter == null && entityData.get(DATA_SHOOTER) > 0) { + shooter = level().getEntity(entityData.get(DATA_SHOOTER)); + } + if (user == null && entityData.get(DATA_USER) > 0) { + user = level().getEntity(entityData.get(DATA_USER)); + } + } + + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ + public boolean canHit(Entity entity) { + if (level().isClientSide()) { + resolveOwners(); + } + 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); + } + if (piercing <= 0) { + // The server decides what the hit does; until its removal arrives the round stays + // where it struck instead of flying on through the hull. + spent = true; + setPos(hit.location()); + setDeltaMovement(Vec3.ZERO); + } + 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; + } + + /** Client only: whether the round already hit something and should no longer be drawn. */ + public boolean isSpent() { + return spent; + } + + @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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java new file mode 100644 index 00000000..c39fa6fe --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 00000000..1f198726 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,191 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +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.util.FastColor; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = (FastColor.ARGB32.red(color) / 255.0F) * 0.9F + random.nextFloat() * 0.1F; + float g = (FastColor.ARGB32.green(color) / 255.0F) * 0.9F + random.nextFloat() * 0.1F; + float b = (FastColor.ARGB32.blue(color) / 255.0F) * 0.9F + random.nextFloat() * 0.1F; + if (FastColor.ARGB32.red(color) == FastColor.ARGB32.green(color)) { + g = r; + } + if (FastColor.ARGB32.red(color) == FastColor.ARGB32.blue(color)) { + b = r; + } + if (FastColor.ARGB32.green(color) == FastColor.ARGB32.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(FastColor.ARGB32.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java new file mode 100644 index 00000000..a89f7c01 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java new file mode 100644 index 00000000..a06fef1a --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 00000000..370301b4 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,146 @@ +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.content.pack.PackManifest; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.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.TooltipFlag; +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; + +import java.util.List; + +/** + * 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.20.6 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); + } + + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, List out, TooltipFlag flag) { + super.appendHoverText(stack, context, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.add((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + + @Override + public 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.20.6/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 00000000..0f89341b --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.removed"), true); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.released"), true); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance)), true); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.no_vehicle"), true); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.displayClientMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 00000000..71b3ed0a --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * The fuel can, {@code MCH_ItemFuel}. It holds 600 units; the durability bar shows how much is + * left. Using it refills it from the coal and charcoal in the inventory, as in the original. + * + *

A can in a fuel slot of the vehicle screen empties into the tank while the vehicle stands on + * something, as in the original. Not in the original: right-clicking a vehicle with the can pours + * up to 100 units at once, which saves opening the screen. + */ +public class FuelItem extends Item { + /** Units a full can holds, the original's maximum damage. */ + public static final int CAPACITY = 600; + /** Units poured into a vehicle per click: what the original moved per fuel slot per update. */ + private static final int POUR = 100; + private static final int PER_COAL = 100; + private static final int PER_CHARCOAL = 75; + + public FuelItem(Properties properties) { + super(properties.durability(CAPACITY)); + } + + public static int contents(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + /** Fills a vehicle's tank from the can. Server only. */ + public static InteractionResult pour(ItemStack stack, AircraftEntity vehicle, Player player) { + int added = vehicle.refuel(player.getAbilities().instabuild ? POUR : Math.min(contents(stack), POUR)); + if (added <= 0) { + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) { + stack.setDamageValue(stack.getDamageValue() + added); + } + vehicle.level().playSound(null, vehicle.getX(), vehicle.getY(), vehicle.getZ(), SoundEvents.BUCKET_EMPTY, SoundSource.NEUTRAL, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } + + /** Charcoal first, then coal, each one worth part of the can, until it is full. */ + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + if (!stack.isDamaged() || player.getAbilities().instabuild) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + if (!level.isClientSide()) { + refill(stack, player.getInventory(), Items.CHARCOAL, PER_CHARCOAL); + refill(stack, player.getInventory(), Items.COAL, PER_COAL); + } + return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide()); + } + + private static void refill(ItemStack can, Inventory inventory, Item coal, int worth) { + for (int slot = 0; slot < inventory.getContainerSize() && can.isDamaged(); slot++) { + ItemStack item = inventory.getItem(slot); + while (item.is(coal) && !item.isEmpty() && can.isDamaged()) { + can.setDamageValue(Math.max(can.getDamageValue() - worth, 0)); + item.shrink(1); + } + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 00000000..8dce2b68 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + player.startUsingItem(hand); + return InteractionResultHolder.consume(player.getItemInHand(hand)); + } + + @Override + public int getUseDuration(ItemStack stack) { + return 72000; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.BOW; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 00000000..14ca5640 --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 00000000..9f19eb4f --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java @@ -0,0 +1,28 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.level.Level; + +/** The parachute, {@code MCH_ItemParachute}: used in mid-air, it opens above the player. */ +public class ParachuteItem extends Item { + public ParachuteItem(Properties properties) { + super(properties.stacksTo(1)); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (player.isPassenger() || player.onGround()) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + if (!level.isClientSide()) { + ParachuteEntity.open(level, player, ParachuteEntity.Kind.ITEM); + player.getItemInHand(hand).consume(1, player); + } + return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide()); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 00000000..3b68bcca --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.moveTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 00000000..ffc75bf7 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + player.startUsingItem(hand); + return InteractionResultHolder.consume(player.getItemInHand(hand)); + } + + @Override + public int getUseDuration(ItemStack stack) { + return 72000; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.SPYGLASS; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 00000000..ace8ccc8 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,124 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.item.component.CustomModelData; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + MCHeli.legacyContent().iconIndex(def.name()) + .ifPresent(index -> stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(index))); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResultHolder.fail(player.getItemInHand(hand)); + } + player.startUsingItem(hand); + return InteractionResultHolder.consume(player.getItemInHand(hand)); + } + + @Override + public int getUseDuration(ItemStack stack) { + return 72000; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.BOW; + } + + @Override + public void releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return; + } + int drawn = Math.max(getUseDuration(stack) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 00000000..7e3bc1c8 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 00000000..f1a42eab --- /dev/null +++ b/minecraft-1.20.6/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 = new ResourceLocation(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.20.6/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 00000000..b02eca5d --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * The wrench, {@code MCH_ItemWrench}. Right-clicking a damaged vehicle repairs 10 hit points, once + * a second, at one point of the wrench's durability. Sneaking switches the vehicle to its next + * paint scheme. + * + *

The original repaired while the use key was held; a click per repair works the same with + * today's item use, which stops as soon as the cursor leaves the hull. + */ +public class WrenchItem extends Item { + /** An iron tool's durability, the original's tool material. */ + private static final int DURABILITY = 250; + private static final int REPAIR = 10; + private static final int COOLDOWN = 20; + + public WrenchItem(Properties properties) { + super(properties.durability(DURABILITY)); + } + + /** Server only. */ + public static InteractionResult useOn(ItemStack stack, AircraftEntity vehicle, Player player, InteractionHand hand) { + if (player.isSecondaryUseActive()) { + return vehicle.nextTexture() ? InteractionResult.SUCCESS : InteractionResult.FAIL; + } + if (player.getCooldowns().isOnCooldown(stack.getItem()) || !vehicle.repair(REPAIR)) { + return InteractionResult.FAIL; + } + player.getCooldowns().addCooldown(stack.getItem(), COOLDOWN); + stack.hurtAndBreak(1, player, LivingEntity.getSlotForHand(hand)); + ModSounds.playAt(vehicle, "wrench", 1.0F, 0.9F + vehicle.level().getRandom().nextFloat() * 0.2F); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 00000000..13a96f96 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes and asks for one by its id; the server finds the + * recipe, takes the ingredients from the inventory and puts the result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addPlayerSlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, ResourceLocation id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeHolder holder = player.level().getServer().getRecipeManager().byKey(id).orElse(null); + if (holder == null || !id.getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + // A shaped recipe lists its empty grid slots too. + List ingredients = recipe.getIngredients().stream().filter(ingredient -> !ingredient.isEmpty()).toList(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.getResultItem(player.registryAccess()).copy()); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } + + /** The player's inventory and, 58 below its top, the hotbar. */ + private void addPlayerSlots(Inventory inventory, int x, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, x + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, x + column * 18, y + 58)); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 00000000..6e4b7a0c --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,166 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addPlayerSlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } + + /** The player's inventory and, 58 below its top, the hotbar. */ + private void addPlayerSlots(Inventory inventory, int x, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, x + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, x + column * 18, y + 58)); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java new file mode 100644 index 00000000..d69c63ec --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java @@ -0,0 +1,229 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGuiContainer}: fuel slots, a parachute slot per ejection + * seat, reloading the weapons from the crew member's own items and the way into the cargo chest. + * + *

Only crew open it, so the client finds the vehicle as the one its player rides. The weapon + * rounds and which weapons can be reloaded right now come from the server through data slots, + * since a client only hears about the ammunition of the seat it sits in. + */ +public class VehicleMenu extends AbstractContainerMenu { + /** Weapons the screen can show; more than any vehicle in the original has. */ + public static final int MAX_WEAPONS = 32; + /** Button ids from zero up reload that weapon; this one opens the cargo chest. */ + public static final int BUTTON_CARGO = 1000; + + public static final int FUEL_X = 8; + public static final int FUEL_Y = 30; + public static final int PARACHUTE_X = 8; + public static final int PARACHUTE_Y = 66; + public static final int INVENTORY_Y = 102; + + private static final int DATA_RELOADABLE = MAX_WEAPONS; + private static final int DATA_SIZE = MAX_WEAPONS + 2; + + private final @Nullable AircraftEntity vehicle; + private final Container equipment; + private final ContainerData data; + private final int parachuteSlots; + private boolean supplied; + + /** The client side, from the open screen packet. */ + public VehicleMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof AircraftEntity v ? v : null, + new SimpleContainer(VehicleInventory.EQUIPMENT_SLOTS), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public VehicleMenu(int containerId, Inventory inventory, AircraftEntity vehicle) { + this(containerId, inventory, vehicle, vehicle.inventory().equipment(), new ServerData(vehicle, inventory.player)); + } + + private VehicleMenu(int containerId, Inventory inventory, @Nullable AircraftEntity vehicle, Container equipment, ContainerData data) { + super(ModMenus.VEHICLE.get(), containerId); + this.vehicle = vehicle; + this.equipment = equipment; + this.data = data; + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + parachuteSlots = def != null ? VehicleInventory.parachuteSlots(def) : 0; + checkContainerSize(equipment, VehicleInventory.EQUIPMENT_SLOTS); + + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + addSlot(new Slot(equipment, i, FUEL_X + i * 18, FUEL_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof FuelItem; + } + }); + } + for (int i = 0; i < VehicleInventory.PARACHUTE_SLOTS; i++) { + boolean active = i < parachuteSlots; + addSlot(new Slot(equipment, VehicleInventory.FUEL_SLOTS + i, PARACHUTE_X + i * 18, PARACHUTE_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return active && stack.getItem() instanceof ParachuteItem; + } + + @Override + public boolean isActive() { + return active; + } + }); + } + addPlayerSlots(inventory, 8, INVENTORY_Y); + addDataSlots(data); + } + + public @Nullable AircraftEntity vehicle() { + return vehicle; + } + + /** Parachute slots in use: one per ejection seat. */ + public int parachuteSlots() { + return parachuteSlots; + } + + /** Magazine and reserve of a weapon together, as the server last reported them. */ + public int rounds(int weapon) { + return weapon >= 0 && weapon < MAX_WEAPONS ? data.get(weapon) : 0; + } + + /** Whether the server would reload this weapon now: the vehicle is down and the items are there. */ + public boolean canReload(int weapon) { + if (weapon < 0 || weapon >= MAX_WEAPONS) { + return false; + } + int bits = (data.get(DATA_RELOADABLE) & 0xFFFF) | (data.get(DATA_RELOADABLE + 1) & 0xFFFF) << 16; + return (bits & 1 << weapon) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (vehicle == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CARGO) { + return vehicle.openCargo(server); + } + if (id >= 0 && id < MAX_WEAPONS && Resupply.supply(server, vehicle, id)) { + supplied = true; + return true; + } + return false; + } + + @Override + public boolean stillValid(Player player) { + return vehicle != null && equipment.stillValid(player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + int equipmentEnd = VehicleInventory.EQUIPMENT_SLOTS; + if (index < equipmentEnd) { + if (!moveItemStackTo(stack, equipmentEnd, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof FuelItem) { + if (!moveItemStackTo(stack, 0, VehicleInventory.FUEL_SLOTS, false)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof ParachuteItem && parachuteSlots > 0) { + int first = VehicleInventory.FUEL_SLOTS; + if (!moveItemStackTo(stack, first, first + parachuteSlots, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** As in the original, closing the screen after a resupply loads every magazine from its reserve. */ + @Override + public void removed(Player player) { + super.removed(player); + if (supplied && vehicle != null && vehicle.weapons() != null && !player.level().isClientSide()) { + vehicle.weapons().reloadAll(); + } + } + + /** Rounds per weapon and the reload bits, worked out on the server whenever the menu syncs. */ + private record ServerData(AircraftEntity vehicle, Player player) implements ContainerData { + @Override + public int get(int index) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons == null) { + return 0; + } + if (index < MAX_WEAPONS) { + WeaponSet set = weapons.set(index); + // Data slots travel as shorts. + return set != null ? Math.min(set.ammo() + set.restAmmo(), Short.MAX_VALUE) : 0; + } + int bits = 0; + if (player instanceof ServerPlayer server) { + for (int i = 0; i < Math.min(weapons.sets().size(), MAX_WEAPONS); i++) { + if (Resupply.canSupply(server, vehicle, i)) { + bits |= 1 << i; + } + } + } + return index == DATA_RELOADABLE ? bits & 0xFFFF : bits >>> 16; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } + + /** The player's inventory and, 58 below its top, the hotbar. */ + private void addPlayerSlots(Inventory inventory, int x, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, x + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, x + column * 18, y + 58)); + } + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java new file mode 100644 index 00000000..0f7ccf72 --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java new file mode 100644 index 00000000..1b565e21 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -0,0 +1,164 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +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, + /** Open the vehicle screen: fuel and parachute slots, reloading, the way to the cargo chest. */ + VEHICLE_SCREEN, + /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ + GUNNER_MODE, + /** Turn night vision on for the sender, in vehicles with {@code EnableNightVision}. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT + } + + 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 VEHICLE_SCREEN -> vehicle.openScreen(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 00000000..02c394df --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +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.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe with this id. + */ +public record DraftingTablePacket(ResourceLocation recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + buf.writeResourceLocation(msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(buf.readResourceLocation()); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 00000000..76090f2e --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +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 request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket 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 equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java new file mode 100644 index 00000000..279b1511 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java new file mode 100644 index 00000000..0056dcc0 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 00000000..4306edfa --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,118 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.serverLevel(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level) + : ModEntities.AA_MISSILE.get().create(level); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java new file mode 100644 index 00000000..97d2cbf4 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 00000000..1de0bfc3 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +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 client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java new file mode 100644 index 00000000..fa0fbea9 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,90 @@ +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 = 11; + + /** 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<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), + new Serverbound<>(TestFlightPacket.TYPE, TestFlightPacket.STREAM_CODEC, TestFlightPacket::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), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + 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), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::handle)); + + private ModNetwork() { + } + + static CustomPacketPayload.Type type(String name) { + return new CustomPacketPayload.Type<>(new ResourceLocation(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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 00000000..89c1644d --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 00000000..ad7f0c85 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 00000000..50f10f74 --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..0a01b93a --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

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

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.ioPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 00000000..e522f525 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,158 @@ +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); + } + PackSubmissions.onLogin(player); + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); + } + + /** The packs the server uses now, as the client should load them. */ + 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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java new file mode 100644 index 00000000..6426f060 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 00000000..1ee72286 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +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.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 00000000..14b2db42 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +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 button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java new file mode 100644 index 00000000..db12a390 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java @@ -0,0 +1,34 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.workshop.TestFlight; +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; + +/** The workshop's Test flight button: fly the vehicle just saved. The server checks who is asking. */ +public record TestFlightPacket(String vehicle) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("test_flight"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TestFlightPacket::encode, TestFlightPacket::decode); + private static final int MAX_NAME = 64; + + public static void encode(TestFlightPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.vehicle, MAX_NAME); + } + + public static TestFlightPacket decode(FriendlyByteBuf buf) { + return new TestFlightPacket(buf.readUtf(MAX_NAME)); + } + + public static void handle(TestFlightPacket msg, ServerPlayer sender) { + if (sender != null) { + TestFlight.start(sender, msg.vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java new file mode 100644 index 00000000..292f643e --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 00000000..22f73709 --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 00000000..b8151034 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.particle; + +import net.minecraft.util.ExtraCodecs; +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; + +/** + * A puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + Codec.INT.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 00000000..e951f496 --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 00000000..e90f274f --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 00000000..dee79e8c --- /dev/null +++ b/minecraft-1.20.6/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 = new ResourceLocation(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-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 00000000..28270640 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,21 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.entity.BlockEntityType; + + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> BlockEntityType.Builder.of(DraftingTableBlockEntity::new, ModBlocks.DRAFTING_TABLE.get()).build(null)); + + private ModBlockEntities() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 00000000..c833e532 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of() + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java new file mode 100644 index 00000000..870aa8b8 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -0,0 +1,72 @@ +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.item.ThrowableItem; +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, and one for + * the equipment and throwables. Those lists are 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)); + public static final Registered EQUIPMENT = Registration.register(Registries.CREATIVE_MODE_TAB, "equipment", + key -> Platform.get().creativeTab(key(VEHICLES)) + .title(Component.translatable("itemGroup.mcheli.equipment")) + .icon(() -> new ItemStack(ModItems.WRENCH.get())) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) + .build()); + + 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(), EQUIPMENT.get()); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 00000000..377a5783 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,34 @@ +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()); + + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + 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-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 00000000..bc6526db --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,170 @@ +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.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.ThrowableEntity; +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())); + + public static final Registered> PARACHUTE = Registration.register(Registries.ENTITY_TYPE, "parachute", key -> EntityType.Builder + .of(ParachuteEntity::new, MobCategory.MISC) + .sized(1.5F, 0.6F) + .clientTrackingRange(10) + .updateInterval(1) + .build(key.location().toString())); + + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key.location().toString())); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key.location().toString())); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key.location().toString())); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key.location().toString())); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key.location().toString())); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 00000000..ca2691f2 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,102 @@ +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.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; +import com.jnhprojects.mcheli.item.WrenchItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; + +import java.util.List; +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. The + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. + */ +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"); + + public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties())); + public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties())); + public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties())); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties())); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties())); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties())); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties())); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties())); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties())); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); + + 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().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) || EQUIPMENT.stream().anyMatch(e -> e.id().getPath().equals(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().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-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java new file mode 100644 index 00000000..0dac0052 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.MenuType; + +/** Our screens with slots. The cargo chest uses vanilla's chest menus and needs no entry here. */ +public final class ModMenus { + public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", + key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); + + private ModMenus() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 00000000..4ed53acd --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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; + } + }); + + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 00000000..4d869373 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 00000000..5244b4d6 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,128 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + /** The colours a team can have. */ + public static final List COLORS = Arrays.stream(ChatFormatting.values()).filter(ChatFormatting::isColor).toList(); + + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.hasPermissions(Commands.LEVEL_GAMEMASTERS); + } + + /** Switches the server's PVP setting and says which way it went. There is no game rule for it here, so it lasts until a restart. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !server.isPvpAllowed(); + server.setPvpAllowed(pvp); + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.displayClientMessage(Component.translatable("message.mcheli.team.no_teams"), true); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.displayClientMessage(Component.translatable("message.mcheli.team.bad_name", trimmed), true); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(COLORS.get(Math.floorMod(color, COLORS.size()))); + team.setAllowFriendlyFire(friendlyFire); + player.displayClientMessage(Component.translatable("message.mcheli.team.created", trimmed), true); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + toRespawnPoint(target); + } + } + + private static void toRespawnPoint(ServerPlayer target) { + MinecraftServer server = target.getServer(); + ServerLevel level = server.getLevel(target.getRespawnDimension()); + BlockPos spawn = target.getRespawnPosition(); + Vec3 at = level != null && spawn != null + ? Player.findRespawnPositionAndUseSpawnBlock(level, spawn, target.getRespawnAngle(), target.isRespawnForced(), false).orElse(null) + : null; + if (at == null) { + level = server.overworld(); + at = Vec3.atBottomCenterOf(level.getSharedSpawnPos()); + } + target.teleportTo(level, at.x, at.y, at.z, target.getYRot(), target.getXRot()); + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.displayClientMessage(Component.translatable("message.mcheli.team.destroyed", count), true); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 00000000..81763693 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,124 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + 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); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java new file mode 100644 index 00000000..f671d8e7 --- /dev/null +++ b/minecraft-1.20.6/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, new ResourceLocation("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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java new file mode 100644 index 00000000..4f4b0dc8 --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java new file mode 100644 index 00000000..4ab83969 --- /dev/null +++ b/minecraft-1.20.6/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((int) Math.ceil(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.20.6/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java new file mode 100644 index 00000000..ff583c8b --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java new file mode 100644 index 00000000..b8142e92 --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java new file mode 100644 index 00000000..fe7b760f --- /dev/null +++ b/minecraft-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java new file mode 100644 index 00000000..e26fca48 --- /dev/null +++ b/minecraft-1.20.6/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, new ResourceLocation(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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java new file mode 100644 index 00000000..533f5fba --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -0,0 +1,82 @@ +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 a crew member's own items, the {@code supplyAmmo} path of the + * original's vehicle screen. One supply costs the weapon's {@code Item} lines and adds + * {@code SuppliedNum} rounds; it only works while the vehicle stands on something. + */ +public final class Resupply { + private Resupply() { + } + + /** {@code canPlayerSupplyAmmo}: whether {@link #supply} would do anything right now. */ + public static boolean canSupply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.set(weapon) : null; + if (set == null || vehicle.isDestroyed() || player.getVehicle() != vehicle || !vehicle.canSupply()) { + return false; + } + if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { + return false; + } + return player.getAbilities().instabuild || canPay(player.getInventory(), set.definition()); + } + + /** Pays for and adds one supply of {@code weapon}. Server only. */ + public static boolean supply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + if (!canSupply(player, vehicle, weapon)) { + return false; + } + WeaponSet set = vehicle.weapons().set(weapon); + if (!player.getAbilities().instabuild) { + pay(player.getInventory(), set.definition()); + } + 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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java new file mode 100644 index 00000000..f7b6e969 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -0,0 +1,417 @@ +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.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.RandomSource; +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; + } + + private static void sound(WeaponSet set, Entity at, String name) { + if (set.soundAllowed) { + WeaponDefinition w = set.definition(); + float rnd = w.soundPitchRandom; + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + } + } + + // 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) { + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? soundName(set) : "ng"); + return true; + } + List spotted = new ArrayList<>(); + 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; + } + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); + } + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "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; + set.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); + set.casUser = shot.user; + set.casShooter = shot.shooter; + set.casStartTick = CasStrike.FIRST_PASS; + 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; + } + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); + set.casStartTick = set.ticksSinceShot(0) + 45; + set.casCount++; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java new file mode 100644 index 00000000..6a9f53cf --- /dev/null +++ b/minecraft-1.20.6/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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 00000000..10167f4a --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +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 java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.serverLevel(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.serverLevel(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} diff --git a/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java new file mode 100644 index 00000000..02f4c39b --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -0,0 +1,598 @@ +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()) { + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); + } + } + 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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java new file mode 100644 index 00000000..3f539030 --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -0,0 +1,505 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +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.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; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; + + /** Aim relative to the hull, following the crew member who operates the weapon. */ + public float rotationYaw; + 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); + } + } + + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + + // 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/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java new file mode 100644 index 00000000..4fe57c2c --- /dev/null +++ b/minecraft-1.20.6/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.phys.Vec3; + +/** + * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server + * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. + */ +public final class TestFlight { + /** How far up to look for room when the vehicle does not fit where the player stands. */ + private static final int MAX_RISE = 48; + + private TestFlight() { + } + + public static void start(ServerPlayer player, String vehicle) { + MinecraftServer server = player.level().getServer(); + if (!server.isSingleplayerOwner(player.getGameProfile())) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_owner_only"), true); + return; + } + if (player.isPassenger()) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_riding"), true); + return; + } + ContentReload.reload(server); + AircraftDefinition def = MCHeli.content().any(vehicle); + if (def == null) { + player.sendSystemMessage(Component.translatable("message.mcheli.workshop.test_flight_no_vehicle", vehicle)); + MCHeli.packs().reportProblems(player::sendSystemMessage); + return; + } + ServerLevel level = player.serverLevel(); + AircraftEntity aircraft = ModEntities.typeFor(def).create(level); + if (aircraft == null) { + return; + } + aircraft.setType(def); + Vec3 start = player.position(); + for (int rise = 0; rise <= MAX_RISE; rise++) { + aircraft.moveTo(start.x, start.y + rise, start.z, player.getYRot(), 0.0F); + if (level.noCollision(aircraft, aircraft.getBoundingBox())) { + break; + } + } + aircraft.startTestFlight(start); + level.addFreshEntity(aircraft); + player.startRiding(aircraft, true); + } +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-1.20.6/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 00000000..82992af9 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-1.20.6/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 00000000..8dfa5bcc --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/models/item/drafting_table.json b/minecraft-1.20.6/src/main/resources/assets/mcheli/models/item/drafting_table.json new file mode 100644 index 00000000..355ce4a7 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/mcheli/models/item/drafting_table.json @@ -0,0 +1,3 @@ +{ + "parent": "mcheli:block/drafting_table" +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-1.20.6/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 00000000..296e5397 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-1.20.6/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 00000000..977381be --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/shaders/post/thermal.json b/minecraft-1.20.6/src/main/resources/assets/mcheli/shaders/post/thermal.json new file mode 100644 index 00000000..10f9a6cc --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/mcheli/shaders/post/thermal.json @@ -0,0 +1,17 @@ +{ + "targets": [ + "swap" + ], + "passes": [ + { + "name": "mcheli_thermal", + "intarget": "minecraft:main", + "outtarget": "swap" + }, + { + "name": "blit", + "intarget": "swap", + "outtarget": "minecraft:main" + } + ] +} diff --git a/minecraft-1.20.6/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-1.20.6/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 00000000..339b9729 Binary files /dev/null and b/minecraft-1.20.6/src/main/resources/assets/mcheli/textures/particle/glow.png differ diff --git a/minecraft-1.20.6/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.fsh b/minecraft-1.20.6/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.fsh new file mode 100644 index 00000000..4a070551 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.fsh @@ -0,0 +1,32 @@ +#version 150 + +// Thermal view, white hot. A post shader only sees colours, so heat is faked: bright and warm +// colours (fire, lava, skin, lamps) read hot, the sky and water cold, and outlines are drawn +// where the brightness changes sharply, like the original's "pencil" shader. + +uniform sampler2D DiffuseSampler; + +in vec2 texCoord; +in vec2 oneTexel; + +out vec4 fragColor; + +const vec3 Gray = vec3(0.3, 0.59, 0.11); + +float heat(vec3 c) { + float luma = dot(c, Gray); + float warmth = clamp(c.r - c.b, 0.0, 1.0); + return clamp(luma * 0.7 + warmth * 0.6, 0.0, 1.0); +} + +void main() { + float c = heat(texture(DiffuseSampler, texCoord).rgb); + float l = heat(texture(DiffuseSampler, texCoord - vec2(oneTexel.x, 0.0)).rgb); + float r = heat(texture(DiffuseSampler, texCoord + vec2(oneTexel.x, 0.0)).rgb); + float u = heat(texture(DiffuseSampler, texCoord - vec2(0.0, oneTexel.y)).rgb); + float d = heat(texture(DiffuseSampler, texCoord + vec2(0.0, oneTexel.y)).rgb); + float edge = clamp(abs(r - l) + abs(d - u), 0.0, 1.0); + + float value = smoothstep(0.08, 0.85, c) + edge * 0.6; + fragColor = vec4(vec3(clamp(value, 0.0, 1.0)), 1.0); +} diff --git a/minecraft-1.20.6/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.json b/minecraft-1.20.6/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.json new file mode 100644 index 00000000..bb338e9f --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.json @@ -0,0 +1,18 @@ +{ + "blend": { + "func": "add", + "srcrgb": "one", + "dstrgb": "zero" + }, + "vertex": "sobel", + "fragment": "mcheli_thermal", + "attributes": [ "Position" ], + "samplers": [ + { "name": "DiffuseSampler" } + ], + "uniforms": [ + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "InSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "OutSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] } + ] +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-1.20.6/src/main/resources/data/mcheli/damage_type/bullet.json new file mode 100644 index 00000000..92ee6766 --- /dev/null +++ b/minecraft-1.20.6/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-1.20.6/src/main/resources/data/mcheli/loot_tables/blocks/drafting_table.json b/minecraft-1.20.6/src/main/resources/data/mcheli/loot_tables/blocks/drafting_table.json new file mode 100644 index 00000000..610afa08 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/loot_tables/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/chain.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/chain.json new file mode 100644 index 00000000..92d099ca --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/container.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/container.json new file mode 100644 index 00000000..9942f143 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": {"tag": "c:chests/wooden"}, + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/drafting_table.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/drafting_table.json new file mode 100644 index 00000000..4429cf70 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": {"item": "minecraft:crafting_table"}, + "F": {"tag": "minecraft:wooden_fences"}, + "P": {"tag": "minecraft:planks"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fgm148.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fgm148.json new file mode 100644 index 00000000..c9c7c87e --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:glass_blocks/colorless"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fgm148_bullet.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fgm148_bullet.json new file mode 100644 index 00000000..bd8a2af9 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:gunpowders"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fim92.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fim92.json new file mode 100644 index 00000000..064eac79 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:glass_blocks/colorless"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fim92_bullet.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fim92_bullet.json new file mode 100644 index 00000000..29bc0018 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:gunpowders"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fuel.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fuel.json new file mode 100644 index 00000000..99e45469 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/fuel.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "C": {"item": "minecraft:coal"}, + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "ICI", + "III" + ], + "result": { + "id": "mcheli:fuel" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/gltd.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/gltd.json new file mode 100644 index 00000000..b129a876 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": {"tag": "c:storage_blocks/iron"}, + "D": {"tag": "c:gems/diamond"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_green.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_green.json new file mode 100644 index 00000000..66bb96dd --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:green_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:custom_model_data": 1, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_red.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_red.json new file mode 100644 index 00000000..dafb3688 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:red_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:custom_model_data": 2, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_violet.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_violet.json new file mode 100644 index 00000000..7944152c --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:purple_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:custom_model_data": 3, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_yellow.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_yellow.json new file mode 100644 index 00000000..ca70c1ad --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:yellow_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:custom_model_data": 4, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/parachute.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/parachute.json new file mode 100644 index 00000000..66118c43 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/parachute.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "S": {"tag": "c:strings"}, + "W": {"tag": "minecraft:wool"} + }, + "pattern": [ + "WWW", + "S S", + " W " + ], + "result": { + "id": "mcheli:parachute" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/rangefinder.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/rangefinder.json new file mode 100644 index 00000000..eb87cd68 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:glass_blocks/colorless"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/rangefinder_recharge.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/rangefinder_recharge.json new file mode 100644 index 00000000..23ac1b09 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + {"item": "mcheli:rangefinder"}, + {"tag": "c:dusts/redstone"} + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/uav_station.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/uav_station.json new file mode 100644 index 00000000..47615ed5 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"tag": "c:gems/diamond"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:storage_blocks/redstone"} + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/uav_station2.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/uav_station2.json new file mode 100644 index 00000000..2281cea7 --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"tag": "c:gems/diamond"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/wrench.json b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/wrench.json new file mode 100644 index 00000000..3b3ab88c --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/mcheli/recipes/wrench.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + " I ", + " II", + "I " + ], + "result": { + "id": "mcheli:wrench" + } +} diff --git a/minecraft-1.20.6/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-1.20.6/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json new file mode 100644 index 00000000..1042db9e --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "mcheli:bullet" + ] +} diff --git a/minecraft-1.20.6/src/main/resources/mcheli_logo.png b/minecraft-1.20.6/src/main/resources/mcheli_logo.png new file mode 100644 index 00000000..95c72198 Binary files /dev/null and b/minecraft-1.20.6/src/main/resources/mcheli_logo.png differ diff --git a/minecraft-1.20.6/src/main/resources/pack.mcmeta b/minecraft-1.20.6/src/main/resources/pack.mcmeta new file mode 100644 index 00000000..f782fbab --- /dev/null +++ b/minecraft-1.20.6/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "${mod_name} resources", + "pack_format": 32, + "supported_formats": [32, 41] + } +} 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 index d42f5228..c699c4e8 100644 --- 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 @@ -1,12 +1,16 @@ package com.jnhprojects.mcheli; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; 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.block.DispenserBlock; import net.minecraft.world.level.storage.LevelResource; import java.util.List; @@ -19,6 +23,11 @@ public final class CommonHooks { private CommonHooks() { } + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + /** * Adds the imported original content to the resource packs (client) and data packs (server). * Without the original zip there is nothing to add. @@ -52,12 +61,16 @@ public static void registerCommands(CommandDispatcher dispat } /** - * 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. + * The vehicle recipes live in {@code data/mcheli/recipe} of the imported content pack. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. */ public static void playerLoggedIn(ServerPlayer player) { PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(player); + } List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() .filter(holder -> holder.id().getNamespace().equals(MCHeli.MOD_ID)) .toList(); diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java index c378489c..d3bfdee5 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -5,6 +5,7 @@ import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.content.pack.PackFolder; import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.content.pack.PendingPacks; import net.minecraft.network.chat.Component; import org.jspecify.annotations.Nullable; @@ -12,7 +13,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.function.Consumer; /** @@ -30,15 +33,19 @@ */ public final class ContentPacks { private static final int PROBLEMS_IN_CHAT = 8; + private static final String SOUNDS = "assets/mcheli/sounds/"; private final Path folder; + private final PendingPacks pending; private final LegacyContent legacy; private volatile List active = List.of(); private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); private List retired = List.of(); ContentPacks(Path gameDir, LegacyContent legacy) { this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); this.legacy = legacy; } @@ -106,13 +113,42 @@ public synchronized ContentRegistry load(boolean serverRules) { found.addAll(registry.problems()); close(retired); + PendingPacks.cleanReplaced(folder); retired = active; active = List.copyOf(accepted); problems = List.copyOf(found); + sounds = soundsOf(accepted); MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); return registry; } + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + private static @Nullable String refusal(ContentPack pack, long size, long total) { if (!ServerConfig.isPackAllowed(pack.id())) { return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java index 21d4185b..1a890651 100644 --- 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 @@ -2,10 +2,13 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; 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.ModMenus; import com.jnhprojects.mcheli.registry.ModParticles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,9 +40,12 @@ public static void init() { legacyContent = LegacyContent.prepare(platform.gameDir()); packs = new ContentPacks(platform.gameDir(), legacyContent); content = packs.load(false); + ModBlocks.init(); + ModBlockEntities.init(); ModDataComponents.init(); ModEntities.init(); ModParticles.init(); + ModMenus.init(); ModItems.init(content); ModCreativeTabs.init(); } 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 index f70a7470..1384fcc4 100644 --- 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 @@ -1,15 +1,21 @@ package com.jnhprojects.mcheli; import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; @@ -17,8 +23,11 @@ import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; +import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; /** @@ -28,15 +37,19 @@ * /mcheli content source and counts * /mcheli list [helicopter|plane|...] loaded vehicles * /mcheli packs content packs in use, and their problems -|plane|...] loaded vehicles * /mcheli give <players> <vehicle> [count] (operators) * /mcheli reload re-read the definitions and packs (operators) + * /mcheli pending packs players have sent, waiting (operators) + * /mcheli approve <pack> put a waiting pack in use (operators) + * /mcheli reject <pack> throw a waiting pack away (operators) * */ public final class McheliCommand { private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); private McheliCommand() { } @@ -62,7 +75,118 @@ public static void register(CommandDispatcher dispatcher) { .executes(ctx -> give(ctx, IntegerArgumentType.getInteger(ctx, "count"))))))) .then(Commands.literal("reload") .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) - .executes(McheliCommand::reload))); + .executes(McheliCommand::reload)) + .then(Commands.literal("pending") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } } private static int status(CommandContext ctx) { @@ -92,6 +216,9 @@ private static int packs(CommandContext ctx) { long weapons = content.weapons().stream().filter(def -> def.pack().equals(pack.id())).count(); source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.entry", pack.id(), pack.manifest().version(), pack.manifest().name(), vehicles, weapons, ContentPack.shortHash(pack.hash())), false); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } } MCHeli.packs().reportProblems(source::sendFailure); return packs.size(); diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java index 5bbb3225..979dd53e 100644 --- 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 @@ -1,8 +1,11 @@ package com.jnhprojects.mcheli; import net.minecraft.core.Holder; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; import net.minecraft.network.protocol.game.ClientboundSoundPacket; import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundSource; @@ -18,8 +21,14 @@ *

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

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. */ public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + private static final Map EVENTS = new ConcurrentHashMap<>(); private ModSounds() { @@ -31,17 +40,25 @@ public static SoundEvent get(String name) { /** Plays {@code name} at the entity for everyone nearby. Call on the server. */ public static void playAt(Entity entity, String name, float volume, float pitch) { - entity.level().playSound(null, entity.getX(), entity.getY(), entity.getZ(), get(name), SoundSource.NEUTRAL, volume, pitch); + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); } /** Plays {@code name} at a position for everyone nearby. Call on the server. */ public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { - level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } } /** Plays {@code name} for one player only, such as a hit confirmation. */ public static void playTo(ServerPlayer player, String name, float volume, float pitch) { - player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, - player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java index eef7dc3e..9db31738 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -23,10 +23,13 @@ public final class ServerConfig { 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.BooleanValue INFINITE_FUEL; 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; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; static { ConfigSpec.Builder builder = ConfigSpec.builder(); @@ -49,6 +52,9 @@ public final class ServerConfig { .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()); + INFINITE_FUEL = builder + .comment("Whether vehicles never run out of fuel. Original: InfinityFuel") + .define("infiniteFuel", false); builder.pop(); builder.push("weapons"); @@ -70,6 +76,15 @@ public final class ServerConfig { .comment("Most the server loads and sends in all, in megabytes. Packs are taken in id order until", "the next one would go over.") .defineInRange("maxTotalSize", 64, 1, 4096); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); builder.pop(); SPEC = builder.build(); @@ -110,6 +125,10 @@ public static boolean infiniteAmmo() { return SPEC.isLoaded() ? INFINITE_AMMO.get() : INFINITE_AMMO.getDefault(); } + public static boolean infiniteFuel() { + return SPEC.isLoaded() ? INFINITE_FUEL.get() : INFINITE_FUEL.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()) { @@ -131,6 +150,14 @@ public static long maxTotalPackBytes() { return (long) (SPEC.isLoaded() ? MAX_TOTAL_SIZE.get() : MAX_TOTAL_SIZE.getDefault()) << 20; } + public static boolean allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + public static boolean isDisabled(String vehicle) { if (!SPEC.isLoaded()) { return false; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 00000000..7c4dbab5 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.box(0.0, 0.0, 0.0, 16.0, 15.0, 16.0); + private static final MapCodec CODEC = simpleCodec(DraftingTableBlock::new); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 00000000..56238f3f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} 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 index 6963150e..62612358 100644 --- 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 @@ -6,28 +6,54 @@ 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.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ParachuteRenderer; import com.jnhprojects.mcheli.client.render.ProjectileRenderer; import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; +import com.jnhprojects.mcheli.client.screen.VehicleScreen; import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; import com.jnhprojects.mcheli.entity.ClientAccess; import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; 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.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; +import net.minecraft.network.chat.Component; 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 net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; import java.util.List; import java.util.Map; @@ -44,8 +70,21 @@ public final class ClientHooks { private ClientHooks() { } + /** Loads a post effect into the game renderer, whose own method is private on some loaders. */ + public interface PostEffects { + void load(ResourceLocation effect); + } + + private static PostEffects postEffects = effect -> { + }; + + static PostEffects postEffects() { + return postEffects; + } + /** During mod construction, on a client only. */ - public static void init() { + public static void init(PostEffects effects) { + postEffects = effects; ClientConfig.load(Platform.get().configDir()); ClientAccess.install(() -> Minecraft.getInstance().player, ClientPacks.INSTANCE); } @@ -72,6 +111,35 @@ public static void registerEntityRenderers(EntityRenderers renderers) { renderers.register(ModEntities.DISPENSED_ITEM.get(), ProjectileRenderer::new); renderers.register(ModEntities.FLARE.get(), FlareRenderer::new); renderers.register(ModEntities.A10.get(), A10Renderer::new); + renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); + } + + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + + /** On the main thread during client setup, once our menu types are registered. */ + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::new); } public static List keyMappings() { @@ -86,14 +154,37 @@ public static Map reloadListeners() public static void clientTickEnd() { CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); + VehicleCamera.tick(); WeaponController.onClientTick(); HudRenderer.tick(); EngineSoundPlayer.tick(); + TargetMarkers.tick(); + WorkshopClient.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.setScreen(new TeamManagerScreen()); + } else { + mc.player.displayClientMessage(Component.translatable("message.mcheli.team.operators_only"), true); + } } /** 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); + GltdController.onCameraAngles(angles); } /** Sets the camera's rotation, roll included, the way Forge's {@code Camera#setRotation} does. */ @@ -169,9 +260,14 @@ public void setRoll(float roll) { } } + /** The field of view for this frame, narrowed while the player zooms through a sight. */ + public static float fov(float fov) { + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); + } + /** Whether to skip drawing the first person hand this frame. */ public static boolean hidesHand() { - return CrewController.hidesHand(); + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); } /** What happens to a press of the use key. */ @@ -185,7 +281,7 @@ public enum UseKey { } public static UseKey onUseKey(InteractionHand hand) { - if (WeaponController.onUseItem()) { + if (WeaponController.onUseItem() || GltdController.onUseItem()) { return UseKey.CONSUME; } return VehiclePicker.onUseItem(hand) ? UseKey.CONSUME_AND_SWING : UseKey.PASS; @@ -202,6 +298,10 @@ public static void beforeRenderLiving(LivingEntity entity, PoseStack pose, float public static void drawHud(GuiGraphics graphics, DeltaTracker delta) { WeaponHud.draw(graphics, delta); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.draw(graphics, delta); } /** Without the original zip there are no vehicles, so say so where the player will see it. */ @@ -211,5 +311,7 @@ public static void loggedIn(LocalPlayer player) { public static void loggedOut() { ClientPacks.INSTANCE.loggedOut(); + WorkshopClient.loggedOut(); + Markers.clear(); } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java index 13f2bad9..d933e518 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.ClientConfig; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.pack.ContentPack; import com.jnhprojects.mcheli.content.pack.ContentSource; @@ -11,6 +12,7 @@ import com.jnhprojects.mcheli.network.PackDataPacket; import com.jnhprojects.mcheli.network.PackListPacket; import com.jnhprojects.mcheli.network.PackRequestPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; import com.jnhprojects.mcheli.platform.Platform; @@ -47,8 +49,8 @@ *

The cache is capped by {@code packs.cacheSize} in the client config: after a download, the * packs used longest ago go first. The current server's packs are always kept. */ -final class ClientPacks implements ClientAccess.PackReceiver { - static final ClientPacks INSTANCE = new ClientPacks(); +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); @@ -123,6 +125,17 @@ public void onPackData(PackDataPacket packet) { } } + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + /** Leaving a server: its packs are no longer needed. */ void loggedOut() { downloads.clear(); diff --git a/minecraft-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 index 18c514c0..54382add 100644 --- 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 @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.client; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; import com.jnhprojects.mcheli.client.hud.HudRenderer; import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; @@ -22,6 +23,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.player.Input; import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; import net.minecraft.util.Mth; import net.minecraft.world.phys.Vec3; import org.joml.Vector3f; @@ -99,6 +101,10 @@ static void onClientTick() { tracking = false; viewVehicle = null; viewSeat = -1; + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } return; } int seat = vehicle.seatOf(player); @@ -109,6 +115,7 @@ static void onClientTick() { cameraIndex = 0; gunnerSight = false; tracking = false; + VehicleCamera.reset(); } Input keys = player.input; @@ -155,6 +162,9 @@ static void onClientTick() { while (KeyBindings.SWITCH_SEAT.consumeClick()) { send(vehicle, CrewActionPacket.Action.SWITCH_SEAT); } + while (KeyBindings.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } while (KeyBindings.SWITCH_CAMERA.consumeClick()) { switchCamera(vehicle, seat); } @@ -164,10 +174,26 @@ static void onClientTick() { } } while (KeyBindings.FOLD.consumeClick()) { - if (pilot) { + if (looksThroughSight(vehicle, seat)) { + VehicleCamera.onZoomKey(player, vehicle); + } else if (pilot) { send(vehicle, CrewActionPacket.Action.FOLD); } } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } + } + while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { + if (!vehicle.isDestroyed()) { + send(vehicle, CrewActionPacket.Action.VEHICLE_SCREEN); + } + } while (KeyBindings.VTOL.consumeClick()) { if (pilot) { send(vehicle, CrewActionPacket.Action.VTOL); @@ -185,6 +211,18 @@ static void onClientTick() { } } + /** + * Whether this seat sees through a sight that can zoom: a camera position or gunner sight, the + * pilot in gunner mode, or the pilot of a UAV. The zoom key folds the wings everywhere else. + */ + private static boolean looksThroughSight(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount() || def.cameraZoom <= 1) { + return false; + } + return eyePosition(vehicle, def, seat) != null || vehicle.isGunnerMode() || def.uav; + } + private static void send(AircraftEntity vehicle, CrewActionPacket.Action action) { ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), action)); } @@ -261,7 +299,7 @@ static void onCameraAngles(CameraAngles event) { 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), + event.setRoll(AircraftRotation.cameraRoll(vehicle.getViewYRot(partialTicks), vehicle.getViewXRot(partialTicks), vehicle.roll(partialTicks), event.yaw(), event.pitch())); } if (firstPerson && missile != null) { @@ -304,9 +342,9 @@ private static void steerWithMouse(AircraftEntity vehicle, LocalPlayer player, C tracking = false; } if (firstPerson && !freeLook) { - event.setYaw(vehicle.getYRot(partialTicks)); + event.setYaw(vehicle.getViewYRot(partialTicks)); if (!ownPitch) { - event.setPitch(vehicle.getXRot(partialTicks)); + event.setPitch(vehicle.getViewXRot(partialTicks)); } } } @@ -388,7 +426,7 @@ private static void moveCamera(Camera camera, AircraftEntity vehicle, AircraftDe } 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), + Vector3f offset = AircraftRotation.toWorld(vehicle.getViewYRot(partialTicks), vehicle.getViewXRot(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, @@ -409,7 +447,7 @@ private static void moveCamera(Camera camera, AircraftEntity vehicle, AircraftDe return missile; } - private static void placeCamera(Camera camera, Vec3 at) { + static void placeCamera(Camera camera, Vec3 at) { MethodHandle setter = cameraSetter(); if (setter == null) { return; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 00000000..a8db0832 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +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; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.screen == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.drawString(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.drawString(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} 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 index 863771b5..dc594e26 100644 --- 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 @@ -20,7 +20,7 @@ public final class KeyBindings { /** 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. */ + /** Zooms through a sight, as the original Z key did; otherwise 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); @@ -32,11 +32,17 @@ public final class KeyBindings { 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); + /** Fuel and parachute slots, reloading and the cargo chest. The original used R, which switches seats here. */ + public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); + /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ + public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, 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); + SWITCH_WEAPON, WEAPON_MODE, FLARE, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); private KeyBindings() { } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 00000000..ff22d864 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,236 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +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; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.screen == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), (w - size) / 2, (h - size) / 2, size, size, 0.0F, 0.0F, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.drawString(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.drawString(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.drawString(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.drawString(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.drawString(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 00000000..62841616 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +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; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final ResourceLocation SCOPE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(SCOPE, (w - size) / 2, (h - size) / 2, size, size, 0.0F, 0.0F, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushPose(); + g.pose().translate(w / 2.0F, h / 2.0F, 0.0F); + g.pose().scale(k, k, 1.0F); + drawReadouts(g, mc.font, player, stack); + g.pose().popPose(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphics g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.drawString(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.drawString(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.drawString(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.drawString(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.drawString(font, ">", 30, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} 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 index 43dcb6de..dfcd0db1 100644 --- 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 @@ -40,6 +40,8 @@ public final class RuntimeAssets { /** Searched in order, so the pack loaded last comes first. Replaced as a whole. */ private static volatile List roots = List.of(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ private static final Map TEXTURES = new ConcurrentHashMap<>(); @@ -54,6 +56,11 @@ public static void setRoots(List folders) { roots = List.copyOf(folders); } + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ public static @Nullable InputStream open(ResourceLocation id) throws IOException { Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); @@ -93,6 +100,11 @@ public static void refreshTextures() { } } + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(ResourceLocation id) { + TEXTURES.put(id, upload(id)); + } + private static boolean upload(ResourceLocation id) { try (InputStream in = open(id)) { if (in == null) { @@ -130,12 +142,24 @@ public static void clearTextures() { } private static @Nullable Path find(String path) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } for (Path root : roots) { - Path file = root.resolve(path).normalize(); - if (file.startsWith(root) && Files.isRegularFile(file)) { + Path file = inRoot(root, path); + if (file != null) { return file; } } return null; } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 00000000..a7d8f86a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +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.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.joml.Vector4f; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta.getGameTimeDeltaPartialTick(false); + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "â–¼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "â—†", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphics g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.getMainCamera(); + Vector3fc forward = camera.getLookVector(); + Vec3 offset = at.subtract(camera.getPosition()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + // 26.x has projectPointToScreen for this; the view bobbing and the sprint widening are left out. + Vector3f view = new Vector3f((float) offset.x, (float) offset.y, (float) offset.z).rotate(camera.rotation().conjugate(new Quaternionf())); + Vector4f clip = mc.gameRenderer.getProjectionMatrix(ClientHooks.fov(mc.options.fov().get())) + .transform(new Vector4f(view, 1.0F)); + Vec3 ndc = new Vec3(clip.x() / clip.w(), clip.y() / clip.w(), clip.z() / clip.w()); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.drawCenteredString(font, symbol, (int) x, (int) y - 4, color); + g.drawCenteredString(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.drawCenteredString(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.drawCenteredString(font, "â—†", ex, ey - 4, color); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 00000000..3429a2d1 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,126 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.PostChain; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +import java.util.function.Consumer; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision and thermal view for those with {@code EnableNightVision}. Both + * reset whenever the player changes seat or leaves the vehicle. + */ +public final class VehicleCamera { + /** The original's camera modes; the HUD reads the ordinal as {@code cam_mode}. */ + public enum Mode { + NORMAL, + NIGHT_VISION, + /** Night vision seen through a white hot filter, the original's "pencil" shader mode. */ + THERMAL + } + + /** The post effect of the thermal view, {@code assets/mcheli/shaders/post/thermal.json}. */ + private static final ResourceLocation THERMAL_EFFECT = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "shaders/post/thermal.json"); + + private static float zoom = 1.0F; + private static Mode mode = Mode.NORMAL; + + private VehicleCamera() { + } + + public static float zoom() { + return zoom; + } + + public static Mode mode() { + return mode; + } + + /** Back to the plain view, on a seat change or after leaving the vehicle. */ + static void reset() { + zoom = 1.0F; + mode = Mode.NORMAL; + } + + /** Keeps the thermal filter loaded while the thermal view is on, and only then. */ + static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + PostChain current = mc.gameRenderer.currentEffect(); + boolean active = current != null && current.getName().equals(THERMAL_EFFECT.toString()); + if (mode == Mode.THERMAL && !active) { + ClientHooks.postEffects().load(THERMAL_EFFECT); + } else if (mode != Mode.THERMAL && active) { + mc.gameRenderer.shutdownEffect(); + } + } + + /** + * {@code zoomCamera}: each press doubles the zoom up to the vehicle's {@code CameraZoom}, and + * the press after that goes back to one. The caller checks that the seat looks through a sight. + */ + static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { + refuse(player); + return; + } + zoom = zoom >= max - 0.01F ? 1.0F : Math.min(zoom * 2.0F, max); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + + /** + * {@code switchCameraMode}: steps from the plain view to night vision, to thermal view and + * back. The server applies night vision as an effect in both of the last two; the thermal + * filter is drawn here. + */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { + Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; + if (mode == Mode.NORMAL || next == Mode.NORMAL) { + nightVision.accept(next != Mode.NORMAL); + } + mode = next; + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + + /** The view's field of view, narrowed by the zoom. */ + public static float fov(float fov) { + return zoom > 1.0F ? fov / zoom : fov; + } + + /** {@code playSoundNG}: the key does nothing in this seat. */ + private static void refuse(LocalPlayer player) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } +} 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 index f5e6111c..d46804e6 100644 --- 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 @@ -92,9 +92,6 @@ static void onClientTick() { 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); 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 index 5b9c40cb..347ac6f1 100644 --- 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 @@ -88,7 +88,7 @@ static void draw(GuiGraphics g, DeltaTracker delta) { } 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()); + status = Component.translatable("hud.mcheli.resupply", KeyBindings.VEHICLE_SCREEN.getTranslatedKeyMessage()); color = RED; } Guidance lock = weapons.guidance(weapons.selected(seat)); 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 index eb89586e..3f9db748 100644 --- 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 @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.client.hud; +import com.jnhprojects.mcheli.client.VehicleCamera; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import com.jnhprojects.mcheli.entity.AircraftEntity; @@ -30,8 +31,7 @@ * 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 + *

Systems that arrive later read as their idle state for now: the vehicle inventory is empty * and the mortar distance is unknown. */ final class HudState { @@ -47,6 +47,8 @@ final class HudState { final List enemyBlips = new ArrayList<>(); private @Nullable AircraftEntity vehicle; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; private int radarRotate; private int radarRotateO; private int altitude; @@ -56,6 +58,7 @@ final class HudState { private final double[] stickXSamples = new double[STICK_SAMPLES]; private final double[] stickYSamples = new double[STICK_SAMPLES]; private int stickSample; + private int fuelWarnCount; /** Colour set by the last {@code Color} line. Like the original it carries over between frames. */ int color = 0xFF000000; @@ -174,8 +177,8 @@ void update(AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, 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.YAW, Mth.wrapDegrees(vehicle.getViewYRot(partialTicks))); + v.set(HudVariable.PITCH, vehicle.getViewXRot(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)); @@ -196,15 +199,15 @@ void update(AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, 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); + v.set(HudVariable.FUEL, vehicle.fuelRatio()); + v.set(HudVariable.LOW_FUEL, lowFuel(vehicle)); 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.CAM_MODE, VehicleCamera.mode().ordinal()); + v.set(HudVariable.CAM_ZOOM, VehicleCamera.zoom()); v.set(HudVariable.AUTO_PILOT, vehicle instanceof PlaneEntity && vehicle.isGunnerMode(player)); VehicleWeapons weapons = vehicle.weapons(); boolean flares = weapons != null && weapons.flares.isFitted(); @@ -213,7 +216,21 @@ void update(AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, 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); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); + } + + /** + * {@code isLowFuel}: below 10% the warning blinks, on for eight seconds out of every fourteen. + * Ticks with each update, as the original counted per frame. + */ + private boolean lowFuel(AircraftEntity vehicle) { + if (fuelWarnCount <= 0) { + fuelWarnCount = 280; + } + fuelWarnCount--; + return fuelWarnCount < 160 && vehicle.maxFuel() > 0 && vehicle.fuelRatio() < 0.1F; } /** The original averaged the last four stick positions, scaled to -1..1, with up positive. */ @@ -269,16 +286,19 @@ private void updateWeapon(AircraftEntity vehicle, int seat) { vars.set(HudVariable.IS_HEAT_WPN, def.maxHeatCount > 0); vars.set(HudVariable.RELOADING, set.isInPreparation()); vars.set(HudVariable.DSP_MT_DIST, def.displayMortarDistance); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; } else { vars.set(HudVariable.IS_HEAT_WPN, 0.0); vars.set(HudVariable.RELOADING, 0.0); vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; } 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); + vars.set(HudVariable.MT_DIST, mortarDistance); } boolean hasWeapon() { @@ -317,7 +337,7 @@ boolean hasMagazine() { case WPN_RM_AMMO -> weaponRestAmmo; case RELOAD_PER -> reloadPercent; case RELOAD_SEC -> reloadSeconds; - case MORTAR_DIST -> -1.0F; + case MORTAR_DIST -> mortarDistance; case MC_VER -> "26.3"; case MOD_VER -> Platform.get().modVersion(); case MOD_NAME -> "MCHeli Rework"; @@ -327,8 +347,8 @@ boolean hasMagazine() { 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 CAM_ZOOM -> VehicleCamera.zoom(); + case UAV_DIST -> Math.max(vehicle.stationDistance(), 0.0); case THROTTLE -> vehicle.visibleThrottle() * 100.0; }; } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 00000000..3efefa3f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.util.FastColor; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends TextureSheetParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z); + setSprite(sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor((FastColor.ARGB32.red(options.color()) / 255.0F), (FastColor.ARGB32.green(options.color()) / 255.0F), (FastColor.ARGB32.blue(options.color()) / 255.0F)); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void render(VertexConsumer buffer, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.render(buffer, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + public ParticleRenderType getRenderType() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, level.random); + } + } +} 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 index a2126506..5535e184 100644 --- 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 @@ -26,8 +26,8 @@ public void render(A10Entity entity, float entityYaw, float partialTicks, PoseSt 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))); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + pose.mulPose(Axis.XP.rotationDegrees(-entity.getViewXRot(partialTicks))); MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(TEXTURE)), light, OverlayTexture.NO_OVERLAY); pose.popPose(); } 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 index 626907fe..2fcf06d6 100644 --- 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 @@ -18,12 +18,21 @@ public class AircraftRenderState { public float roll; /** Helicopter main rotor or plane propeller angle. */ public float rotorAngle; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; public float throttle; // Part states, 0 to 90 degrees public float canopy; public float landingGear; public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; public float nozzle; public float wing; @@ -43,6 +52,8 @@ public class AircraftRenderState { public boolean localFirstPerson; /** Shade for the unlit glass, from the light at the vehicle. */ public int glassColor = 0xFFFFFFFF; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ public WeaponPartState[] weaponParts = new WeaponPartState[0]; diff --git a/minecraft-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 index 29086b0f..93e7b126 100644 --- 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 @@ -1,20 +1,27 @@ package com.jnhprojects.mcheli.client.render; +import net.minecraft.client.renderer.RenderType; import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; 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.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.math.Axis; import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LevelRenderer; import net.minecraft.client.renderer.LightTexture; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.culling.Frustum; @@ -23,14 +30,23 @@ import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.resources.ResourceLocation; import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.AABB; import org.jspecify.annotations.Nullable; +import java.util.List; + /** * 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 { + private static final float[] HULL_COLOR = {0.0F, 0.88F, 1.0F}; + private static final float[] GENERATED_COLOR = {1.0F, 0.75F, 0.0F}; + /** Ropes take the colour of brown wool. */ + private static final ResourceLocation ROPE = ResourceLocation.withDefaultNamespace("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; + /** Reused every frame; rendering is single threaded. */ private final AircraftRenderState state = new AircraftRenderState(); @@ -45,13 +61,12 @@ private void extractRenderState(T entity, AircraftRenderState state, float parti AircraftDefinition def = entity.definition(); state.definition = def; state.texture = entity.texture(); - state.yaw = entity.getYRot(partialTicks); - state.pitch = entity.getXRot(partialTicks); + state.yaw = entity.getViewYRot(partialTicks); + state.pitch = entity.getViewXRot(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); @@ -60,6 +75,16 @@ private void extractRenderState(T entity, AircraftRenderState state, float parti state.canopy = angle(entity.canopy(), partialTicks); state.landingGear = angle(entity.landingGear(), partialTicks); state.hatch = angle(entity.hatch(), partialTicks); + if (entity instanceof HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getViewYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getViewXRot(partialTicks) : state.pitch; + } if (entity instanceof PlaneEntity plane) { state.nozzle = angle(plane.nozzle(), partialTicks); state.wing = angle(plane.wing(), partialTicks); @@ -199,6 +224,15 @@ private static AABB cullingBox(AircraftEntity entity) { @Override public void render(T entity, float entityYaw, float partialTicks, PoseStack poseStack, MultiBufferSource buffers, int light) { extractRenderState(entity, state, partialTicks, light); + if (state.definition != null && entityRenderDispatcher.shouldRenderHitBoxes()) { + showHull(entity, state.definition, poseStack, buffers); + } + draw(state, poseStack, buffers); + super.render(entity, entityYaw, partialTicks, poseStack, buffers, light); + } + + /** The vehicle and what hangs off it, from a state alone; the workshop draws a model with no entity this way. */ + public static void draw(AircraftRenderState state, PoseStack poseStack, MultiBufferSource buffers) { AircraftDefinition def = state.definition; Mesh mesh = def != null ? MeshCache.INSTANCE.get(def.folder(), def.name()) : null; if (mesh != null) { @@ -210,9 +244,98 @@ public void render(T entity, float entityYaw, float partialTicks, PoseStack pose 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(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, buffers); + } + if (state.searchLight) { + searchLights(def, state, poseStack, buffers); + } poseStack.popPose(); } - super.render(entity, entityYaw, partialTicks, poseStack, buffers, light); + } + + /** + * With F3+B on, outlines the hull boxes next to vanilla's main box: what projectiles hit and + * what other vehicles collide with. The definition's own boxes are cyan, the ones generated + * from the model amber. + */ + private static void showHull(AircraftEntity entity, AircraftDefinition def, PoseStack pose, MultiBufferSource buffers) { + List hull = entity.hullBoxes(); + if (hull.size() < 2) { + return; + } + VertexConsumer lines = buffers.getBuffer(RenderType.lines()); + int listed = def.boundingBoxes.size(); + // The first is the main box, which vanilla draws itself; then the listed boxes, then the generated ones. + for (int i = 1; i < hull.size(); i++) { + float[] c = i <= listed ? HULL_COLOR : GENERATED_COLOR; + LevelRenderer.renderLineBox(pose, lines, hull.get(i).move(-entity.getX(), -entity.getY(), -entity.getZ()), c[0], c[1], c[2], 1.0F); + } + } + + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.mulPose(Axis.YN.rotationDegrees(state.searchLightYaw - state.yaw + light.yaw())); + pose.mulPose(Axis.XP.rotationDegrees(state.searchLightPitch + 90.0F - state.pitch + light.pitch())); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.mulPose(Axis.YN.rotationDegrees(light.yaw() + steer)); + pose.mulPose(Axis.XP.rotationDegrees(90.0F + light.pitch())); + } + Glow.cone(pose, buffers, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers) { + int light = state.lightCoords; + float length = state.ropeLength; + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(ROPE)); + PoseStack.Pose p = pose.last(); + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.addVertex(p, x, y, z).setColor(0xFFFFFFFF).setUv(u, v).setOverlay(OverlayTexture.NO_OVERLAY).setLight(light).setNormal(p, 0.0F, 1.0F, 0.0F); } /** Vehicles bind their own textures per part; this is only for vanilla's bookkeeping. */ diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 00000000..7279b902 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.culling.Frustum; +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; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + public boolean shouldRender(ChainEntity chain, Frustum frustum, double x, double y, double z) { + Entity towed = chain.towed(); + return super.shouldRender(chain, frustum, x, y, z) || towed != null && frustum.isVisible(towed.getBoundingBox()); + } + + @Override + public void render(ChainEntity chain, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + Entity tow = chain.tow(); + Entity towed = chain.towed(); + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + if (mesh != null && tow != null && towed != null) { + Vec3 start = towed.getPosition(partialTicks); + Vec3 from = start.subtract(chain.getPosition(partialTicks)); + Vec3 span = tow.getPosition(partialTicks).subtract(start); + double length = span.length(); + if (length > LINK) { + Vec3 step = span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(TEXTURE)); + pose.pushPose(); + pose.translate(from.x, from.y, from.z); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step.x, step.y, step.z); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-yaw)); + pose.mulPose(Axis.ZP.rotationDegrees(pitch)); + MeshRenderer.emit(mesh.groups(), pose.last(), buffer, light, OverlayTexture.NO_OVERLAY, GREY); + pose.popPose(); + } + pose.popPose(); + } + } + super.render(chain, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ChainEntity chain) { + return TEXTURE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 00000000..fc238c88 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,38 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +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.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public void render(DraftingTableBlockEntity table, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light, int overlay) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.mulPose(Axis.YP.rotationDegrees(-table.getBlockState().getValue(DraftingTableBlock.ROTATION) * 45.0F + 180.0F)); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(TEXTURE)), light, OverlayTexture.NO_OVERLAY, TINT); + pose.popPose(); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 00000000..42782a0e --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +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; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final ResourceLocation texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + @Override + public void render(T entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + pose.mulPose(Axis.XP.rotationDegrees(entity.getViewXRot(partialTicks))); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(texture)), light, OverlayTexture.NO_OVERLAY, tint); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(T entity) { + return 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 index f80e3aeb..5be980cd 100644 --- 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 @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; import com.mojang.blaze3d.platform.NativeImage; import net.minecraft.resources.ResourceLocation; import org.slf4j.Logger; @@ -45,6 +46,7 @@ int at(float u, float v) { private final Map> textures = new HashMap<>(); private final Map, Map> splits = new IdentityHashMap<>(); + private final Map, Map, List>> buckets = new IdentityHashMap<>(); private GlassSplit() { } @@ -57,9 +59,20 @@ Split split(List groups, ResourceLocation texture) { return splits.computeIfAbsent(groups, g -> new HashMap<>()).computeIfAbsent(texture, t -> compute(groups, t)); } + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + void clear() { textures.clear(); splits.clear(); + buckets.clear(); } private Split compute(List groups, ResourceLocation texture) { 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 index 305050d0..baed9729 100644 --- 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 @@ -22,6 +22,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.resources.ResourceLocation; import net.minecraft.util.FastColor; +import net.minecraft.util.Mth; import net.minecraft.world.level.Level; import net.minecraft.world.level.LightLayer; import net.minecraft.world.phys.Vec3; @@ -55,6 +56,27 @@ public String toString() { private static final RenderType HALO = Types.halo(); + /** + * The particle render type for {@code mcheli:smoke}: vanilla's translucent particles with depth + * writes off. A puff's clear edge would otherwise hide the puffs drawn after it, and a smoke + * cloud is mostly overlapping puffs. + */ + public static final ParticleRenderType SMOKE_LAYER = new ParticleRenderType() { + @Override + public BufferBuilder begin(Tesselator tesselator, TextureManager textures) { + RenderSystem.depthMask(false); + RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + return tesselator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE); + } + + @Override + public String toString() { + return "MCHELI_SMOKE"; + } + }; + private static final int FULL_BRIGHT = LightTexture.FULL_BRIGHT; private Glow() { @@ -137,6 +159,25 @@ public static void streak(PoseStack pose, MultiBufferSource buffers, Vec3 toCame vertex(buffer, p, sx, sy, sz, 0.0F, 0.5F, head); } + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, MultiBufferSource buffers, float height, float radius, int start, int end) { + VertexConsumer buffer = buffers.getBuffer(HALO); + PoseStack.Pose p = pose.last(); + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + } + private static int alpha(float strength) { return Math.clamp(Math.round(strength * 255.0F), 0, 255); } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 00000000..3c2fd9bf --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,89 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +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; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + @Override + public void render(GltdEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + float bodyYaw = entity.getViewYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + boolean drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + float headYaw = viewer != null ? viewer.getViewYRot(partialTicks) : bodyYaw; + float headPitch = viewer != null ? viewer.getViewXRot(partialTicks) : 0.0F; + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(TEXTURE)); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-bodyYaw)); + draw(mesh.part("$body"), pose, buffer, light); + pose.popPose(); + if (drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-headYaw)); + pose.mulPose(Axis.XP.rotationDegrees(headPitch)); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, buffer, light); + pose.popPose(); + } + } + if (viewer != null) { + float yaw = headYaw * Mth.DEG_TO_RAD; + float pitch = headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = entity.getPosition(partialTicks).add(head).subtract(entityRenderDispatcher.camera.getPosition()); + pose.pushPose(); + pose.translate(head.x, head.y, head.z); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, buffers, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(GltdEntity entity) { + return TEXTURE; + } + + private static void draw(List groups, PoseStack pose, VertexConsumer buffer, int light) { + if (!groups.isEmpty()) { + MeshRenderer.emit(groups, pose.last(), buffer, light, OverlayTexture.NO_OVERLAY); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 00000000..7d71fa7d --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; + +import java.util.List; + +/** + * {@code MCH_RenderParachute}: the item's round canopy, or the ejection seat with its canopy, + * from the models the import brought in. + */ +public class ParachuteRenderer extends EntityRenderer { + private static final ResourceLocation ROUND = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute1.png"); + private static final ResourceLocation SEAT = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute2.png"); + + public ParachuteRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + @Override + public void render(ParachuteEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + List groups = groups(entity); + if (!groups.isEmpty()) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + MeshRenderer.emit(groups, pose.last(), buffers.getBuffer(RenderType.entityCutout(getTextureLocation(entity))), light, OverlayTexture.NO_OVERLAY); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ParachuteEntity entity) { + return entity.kind() == ParachuteEntity.Kind.ITEM ? ROUND : SEAT; + } + + private static List groups(ParachuteEntity entity) { + if (entity.kind() == ParachuteEntity.Kind.ITEM) { + Mesh mesh = MeshCache.INSTANCE.get("", "parachute1"); + return mesh != null ? mesh.groups() : List.of(); + } + Mesh mesh = MeshCache.INSTANCE.get("", "parachute2"); + boolean canopy = entity.kind() == ParachuteEntity.Kind.DROP || entity.isOpen(); + return mesh != null ? mesh.part(canopy ? "$parachute" : "$seat") : List.of(); + } +} 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 index c367447b..09c4faf3 100644 --- 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 @@ -1,5 +1,7 @@ package com.jnhprojects.mcheli.client.render; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; @@ -22,7 +24,9 @@ import com.jnhprojects.mcheli.content.aircraft.part.WingPart; import com.jnhprojects.mcheli.model.Mesh; import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.math.Axis; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.RenderType; @@ -82,19 +86,19 @@ void submitAll() { nozzles(plane); wings(plane); propellers(plane); - submit(mesh.body()); + hull(); } case HelicopterDefinition heli -> { - submit(mesh.body()); + hull(); rotors(heli); } case VehicleDefinition vehicle -> { - submit(mesh.body()); + hull(); for (VehiclePart part : vehicle.parts) { vehiclePart(vehicle, part); } } - default -> submit(mesh.body()); + default -> hull(); } weapons(); rotatingParts(); @@ -110,10 +114,23 @@ void submitAll() { movingParts(def.canopies, state.canopy); } + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + // Kind specific parts private void rotors(HelicopterDefinition heli) { - for (RotorDefinition rotor : heli.rotors) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); List blade = part(rotor.part()); if (blade.isEmpty() || isZero(rotor.axis())) { continue; @@ -124,7 +141,8 @@ private void rotors(HelicopterDefinition heli) { pose.pushPose(); Vector3 p = rotor.position(); translate(p, 1.0F); - rotate(state.rotorAngle + i * step, rotor.axis()); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : state.rotorAngle + i * step, rotor.axis()); if (!rotor.legacyPivot()) { translate(p, -1.0F); } @@ -480,12 +498,34 @@ private void submit(List groups) { if (groups.isEmpty()) { return; } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + ResourceLocation own = RuntimeAssets.texture(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderType.entityCutout(own), RenderType.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, ResourceLocation texture, RenderType opaqueType, RenderType glassType) { 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); + VertexConsumer buffer = buffers.getBuffer(glassType); + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + if (state.inGui) { + MeshRenderer.emit(split.glass(), pose.last(), buffer, light, overlay, glassColor); + } else { + MeshRenderer.emitFrontFaces(split.glass(), pose.last(), buffer, light, overlay, glassColor); + } } } 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 index 5a34b6ee..f4c2abb7 100644 --- 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 @@ -82,15 +82,15 @@ private static void extractRenderState(ProjectileEntity entity, State state, flo 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(); + state.yaw = entity.getViewYRot(partialTicks); + state.pitch = entity.getViewXRot(partialTicks); + state.visible = !entity.isSpent() && (!(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); + state.glowKind = inWater || entity.isSpent() ? 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); 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 index 7b2b5872..98ff23fd 100644 --- 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 @@ -27,8 +27,8 @@ public static void beforeRender(LivingEntity rider, PoseStack pose, float partia if (!(rider instanceof Player) || !(rider.getVehicle() instanceof AircraftEntity vehicle)) { return; } - float yaw = vehicle.getYRot(partialTicks); - float pitch = vehicle.getXRot(partialTicks); + float yaw = vehicle.getViewYRot(partialTicks); + float pitch = vehicle.getViewXRot(partialTicks); float roll = vehicle.roll(partialTicks); if (pitch == 0.0F && roll == 0.0F) { return; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 00000000..3eb52fe0 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,39 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +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; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + @Override + public void render(ThrowableEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + String name = entity.definitionName(); + Mesh mesh = name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", name); + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(getTextureLocation(entity))), light, OverlayTexture.NO_OVERLAY); + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(ThrowableEntity entity) { + return ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/throwable/" + entity.definitionName() + ".png"); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 00000000..ab90fa68 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +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; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + @Override + public void render(UavStationEntity entity, float entityYaw, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light) { + int index = entity.kind() == UavStationEntity.PORTABLE ? 1 : 0; + float cover = entity.cover(partialTicks); + boolean lit = index == 1 ? cover > 0.95F : entity.isLinked(); + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + ResourceLocation texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[index] + (lit ? "_on" : "") + ".png"); + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(texture)); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-entity.getViewYRot(partialTicks))); + if (index == 0) { + draw(pose, buffer, mesh.groups(), light); + } else { + draw(pose, buffer, mesh.part("$body"), light); + folding(pose, buffer, mesh.part("$cover"), cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, buffer, mesh.part("$laptop_cover"), cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, buffer, mesh.part("$display"), cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.render(entity, entityYaw, partialTicks, pose, buffers, light); + } + + @Override + public ResourceLocation getTextureLocation(UavStationEntity entity) { + return ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[entity.kind() == UavStationEntity.PORTABLE ? 1 : 0] + ".png"); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, VertexConsumer buffer, List part, float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.mulPose(Axis.XN.rotationDegrees(angle)); + pose.translate(0.0F, -y, -z); + draw(pose, buffer, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, VertexConsumer buffer, List groups, int light) { + if (!groups.isEmpty()) { + MeshRenderer.emit(groups, pose.last(), buffer, light, OverlayTexture.NO_OVERLAY, TINT); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 00000000..be430bf1 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,326 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.item.crafting.ShapedRecipe; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the client's recipe manager, which the server fills on join. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List>> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 400; + imageHeight = 240; + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); + selectList(list); + } + + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.setScreen(new WorkshopHomeScreen()); + } + + /** Every MCHeli crafting recipe, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + for (RecipeHolder holder : minecraft.level.getRecipeManager().getAllRecipesFor(RecipeType.CRAFTING)) { + ItemStack result = resultOf(holder); + if (holder.id().getNamespace().equals(MCHeli.MOD_ID) && !result.isEmpty() + && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(holder); + } + } + for (List> entries : lists) { + entries.sort(Comparator.comparing(e -> resultOf(e).getHoverName().getString())); + } + } + + private ItemStack resultOf(RecipeHolder holder) { + return holder.value().getResultItem(minecraft.level.registryAccess()); + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeHolder current() { + List> entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeHolder entry = current(); + return entry != null ? resultOf(entry) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static ResourceLocation pageTexture(String name, int page) { + return ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeHolder entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedContents contents = new StackedContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return contents.canCraft(entry.value(), null); + } + + private void create() { + RecipeHolder entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, PAGE_WIDTH / 2, PAGE_HEIGHT / 2, 0.0F, 0.0F, + PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeHolder entry = current(); + if (entry == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.drawString(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.drawString(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.value(), mouseX, mouseY); + graphics.renderItem(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphics graphics, CraftingRecipe recipe, int mouseX, int mouseY) { + List slots = recipe.getIngredients(); + int width = recipe instanceof ShapedRecipe shaped ? shaped.getWidth() : 3; + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + ItemStack[] options = slots.get(i).getItems(); + if (options.length == 0) { + continue; + } + ItemStack stack = options[tick % options.length]; + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.renderItem(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.renderTooltip(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphics graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.drawString(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 00000000..3ec2606f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,136 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamManager.COLORS.indexOf(ChatFormatting.RED); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamManager.COLORS.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + ChatFormatting teamColor = TeamManager.COLORS.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getName()).withStyle(teamColor))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphics graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.drawString(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.drawString(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 00000000..c70fc4bc --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 166; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.drawString(font, label, 8, 6, color, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.drawString(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java new file mode 100644 index 00000000..e4e69d52 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java @@ -0,0 +1,186 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGui}: fuel and parachute slots on the left, the weapon + * panel on the right with its reload cost and button, and a button into the cargo chest for + * vehicles that have one. The panel is drawn from the chest texture so it looks like vanilla. + */ +public class VehicleScreen extends AbstractContainerScreen { + private static final ResourceLocation CHEST = ResourceLocation.withDefaultNamespace("textures/gui/container/generic_54.png"); + private static final ResourceLocation SLOT = ResourceLocation.withDefaultNamespace("container/slot"); + /** Height of the part above the player inventory. */ + private static final int TOP = 88; + private static final int PANEL_X = 72; + private static final int PANEL_WIDTH = 96; + private static final int LABEL = 0xFF404040; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF5555; + private static final int GREEN = 0xFF55FF55; + + private int weapon; + private boolean cargoButton; + private @Nullable Button reload; + private @Nullable Button previous; + private @Nullable Button next; + + public VehicleScreen(VehicleMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = TOP + 96; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + previous = addRenderableWidget(Button.builder(Component.literal("<"), b -> step(-1)) + .bounds(leftPos + PANEL_X, topPos + 68, 16, 16).build()); + reload = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.reload"), b -> press(weapon)) + .bounds(leftPos + PANEL_X + 18, topPos + 68, PANEL_WIDTH - 36, 16).build()); + next = addRenderableWidget(Button.builder(Component.literal(">"), b -> step(1)) + .bounds(leftPos + PANEL_X + PANEL_WIDTH - 16, topPos + 68, 16, 16).build()); + AircraftEntity vehicle = menu.vehicle(); + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + cargoButton = def != null && def.cargoRows() > 0; + if (cargoButton) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.cargo"), b -> press(VehicleMenu.BUTTON_CARGO)) + .bounds(leftPos + imageWidth - 52, topPos + 3, 46, 13).build()); + } + updateButtons(); + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + private int weaponCount() { + AircraftEntity vehicle = menu.vehicle(); + VehicleWeapons weapons = vehicle != null ? vehicle.weapons() : null; + return weapons != null ? Math.min(weapons.sets().size(), VehicleMenu.MAX_WEAPONS) : 0; + } + + private void step(int direction) { + int count = weaponCount(); + if (count > 0) { + weapon = Math.floorMod(weapon + direction, count); + updateButtons(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + private void updateButtons() { + if (reload == null || previous == null || next == null) { + return; + } + int count = weaponCount(); + reload.visible = count > 0; + previous.visible = count > 1; + next.visible = count > 1; + reload.active = menu.canReload(weapon); + AircraftEntity vehicle = menu.vehicle(); + boolean grounded = vehicle != null && vehicle.canSupply(); + reload.setTooltip(grounded ? null : Tooltip.create(Component.translatable("gui.mcheli.vehicle.reload_hint"))); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + renderTooltip(graphics, mouseX, mouseY); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + int x = leftPos; + int y = topPos; + // The chest's title bar, its plain middle stretched down to the inventory, then the inventory. + graphics.blit(CHEST, x, y, 0.0F, 0.0F, imageWidth, 17, 256, 256); + graphics.blit(CHEST, x, y + 17, imageWidth, TOP - 17, 0.0F, 12.0F, imageWidth, 1, 256, 256); + graphics.blit(CHEST, x, y + TOP, 0.0F, 126.0F, imageWidth, 96, 256, 256); + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + graphics.blitSprite(SLOT, x + VehicleMenu.FUEL_X + i * 18 - 1, y + VehicleMenu.FUEL_Y - 1, 18, 18); + } + for (int i = 0; i < menu.parachuteSlots(); i++) { + graphics.blitSprite(SLOT, x + VehicleMenu.PARACHUTE_X + i * 18 - 1, y + VehicleMenu.PARACHUTE_Y - 1, 18, 18); + } + graphics.fill(x + PANEL_X - 2, y + 17, x + PANEL_X + PANEL_WIDTH + 2, y + 66, 0xFF373737); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + AircraftEntity vehicle = menu.vehicle(); + // Long names only give way to the Cargo button, where there is one. + int titleWidth = cargoButton ? imageWidth - 64 : imageWidth - 16; + graphics.drawString(font, font.plainSubstrByWidth(title.getString(), titleWidth), titleLabelX, titleLabelY, LABEL, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, LABEL, false); + if (vehicle == null) { + return; + } + if (vehicle.maxFuel() > 0) { + // A percentage and a gauge: the exact units would run into the weapon panel. + int percent = Math.round(vehicle.fuelRatio() * 100.0F); + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.fuel", percent), VehicleMenu.FUEL_X, VehicleMenu.FUEL_Y - 11, LABEL, false); + int gaugeY = VehicleMenu.FUEL_Y + 18; + int width = VehicleInventory.FUEL_SLOTS * 18 - 2; + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width, gaugeY + 3, 0xFF373737); + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width * percent / 100, gaugeY + 3, percent <= 10 ? RED : GREEN); + } + if (menu.parachuteSlots() > 0) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.parachute"), VehicleMenu.PARACHUTE_X, VehicleMenu.PARACHUTE_Y - 11, LABEL, false); + } + weaponPanel(graphics, vehicle); + } + + /** Name, rounds carried against the maximum, and what one reload costs. */ + private void weaponPanel(GuiGraphics graphics, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null && weaponCount() > 0 ? weapons.set(Math.min(weapon, weaponCount() - 1)) : null; + if (set == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.no_weapons"), PANEL_X, 20, WHITE, false); + return; + } + graphics.drawString(font, font.plainSubstrByWidth(set.displayName(), PANEL_WIDTH), PANEL_X, 20, WHITE, false); + int rounds = menu.rounds(weapon); + int max = set.maxAmmo(); + int color = rounds == 0 && max > 0 ? RED : rounds >= max ? GREEN : WHITE; + graphics.drawString(font, rounds + " / " + max, PANEL_X, 31, color, false); + int itemX = PANEL_X; + for (RoundItem cost : set.definition().roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null) { + continue; + } + graphics.renderItem(new ItemStack(item), itemX, 42); + graphics.drawString(font, String.valueOf(cost.count()), itemX + 17, 47, WHITE, false); + itemX += 32; + if (itemX > PANEL_X + PANEL_WIDTH - 16) { + break; + } + } + } +} diff --git a/minecraft-1.21.1/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 index 8f91e640..b32040f5 100644 --- a/minecraft-1.21.1/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 @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.ModSounds; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.entity.EngineSoundSource; import net.minecraft.client.Minecraft; @@ -10,25 +11,29 @@ import net.minecraft.util.RandomSource; /** - * The looping engine sound of one vehicle. Ported from {@code MCH_SoundUpdater}. + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. * - *

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

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. */ final class EngineSound extends RuntimeSoundInstance { private static final float MAX_DOPPLER = 0.2F; private final AircraftEntity vehicle; private final EngineSoundSource source; + private final boolean rotor; private float doppler; private double lastDistance = -1.0; - EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound) { + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); this.vehicle = vehicle; this.source = source; + this.rotor = rotor; this.looping = true; this.delay = 0; update(); @@ -53,8 +58,9 @@ private void update() { volume = 0.0F; return; } - volume = def.soundVolume * source.engineVolume(); - float pitch = def.soundPitch * source.enginePitch(); + volume = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); if (aboard) { x = vehicle.getX(); diff --git a/minecraft-1.21.1/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 index b0d46e30..62e52284 100644 --- a/minecraft-1.21.1/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 @@ -12,15 +12,16 @@ import java.util.Map; /** - * Starts engine loops for vehicles near the player and restarts them after they fall silent. - * The sounds themselves track their vehicle until it stops or disappears. + * Starts engine and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. */ public final class EngineSoundPlayer { /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ private static final int RESTART_DELAY = 20; - private static final Map PLAYING = new HashMap<>(); - private static final Map COOLDOWN = new HashMap<>(); + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); private EngineSoundPlayer() { } @@ -44,22 +45,31 @@ public static void tick() { if (!(entity instanceof AircraftEntity vehicle) || !(entity instanceof EngineSoundSource source)) { continue; } - int id = vehicle.getId(); AircraftDefinition def = vehicle.definition(); - if (def == null || PLAYING.containsKey(id) || COOLDOWN.containsKey(id)) { + if (def == null) { continue; } - String sound = def.soundOrDefault(); double range = def.soundRange * 1.2; - if (sound.isEmpty() || vehicle.distanceToSqr(player) > range * range) { + if (vehicle.distanceToSqr(player) > range * range) { continue; } - if (def.soundVolume * source.engineVolume() > 0.01F) { - EngineSound loop = new EngineSound(vehicle, source, sound); - mc.getSoundManager().play(loop); - PLAYING.put(id, loop); - COOLDOWN.put(id, RESTART_DELAY); - } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); } } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..26401727 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; + +/** + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, so the undo history and the view are + * as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + + public static void tick() { + tickSubmit(); + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.screen == null) { + mc.setScreen(screen); + } + } + } + + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + + public static void loggedOut() { + submitting = null; + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..cc44546a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,201 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.home.packs"), b -> minecraft.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().getName() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.drawString(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.drawString(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..b8abc314 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import net.minecraft.Util; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Util.getPlatform().openFile(folder.toFile()); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.setScreen(new WorkshopHomeScreen()); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + if (rows.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphics graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphics graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.drawString(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..c3a2fb46 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1671 @@ +package com.jnhprojects.mcheli.client.workshop; + +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import org.lwjgl.opengl.GL11; +import org.joml.Matrix4f; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.LightTexture; +import com.mojang.math.Axis; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.platform.Lighting; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.AssetImport; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

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

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

    *
  • 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.
  • + *
  • Blades fold and unfold one after another at the same pace on both sides, from the + * synced fold state, instead of each client stepping its own copy.
  • *
*/ public class HelicopterEntity extends AircraftEntity implements EngineSoundSource { @@ -37,9 +43,25 @@ public class HelicopterEntity extends AircraftEntity implements EngineSoundSourc private static final float ROLL_RATE = 1.8F; private static final double MIN_SPEED = 0.07; + private static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + private double currentSpeed = MIN_SPEED; private float rotorAngle; public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; public HelicopterEntity(EntityType type, Level level) { super(type, level); @@ -92,11 +114,210 @@ public float rotorAngle(float partialTicks) { return prev + (rotorAngle - prev) * partialTicks; } + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, (input.contains("FoldBlade") ? input.getBoolean("FoldBlade") : false) ? FOLDED : UNFOLDED); + } + /** 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()) { + } else if (!isGunnerMode() && isLevelEnough() && canUseBlades()) { setHovering(true); } } @@ -105,10 +326,9 @@ 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(); + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); } @Override @@ -126,7 +346,8 @@ public void applyPilotLook(float dYaw, float dPitch) { if (!isNearGround(2)) { yaw = clamp(x, 40.0F * def.mobilityYaw) * LOOK_SCALE; } - if (!isHovering()) { + // Standing on its skids or wheels a helicopter stays level; the mouse only tilts it in the air. + if (!isHovering() && !onGround()) { pitch = clamp(y, 40.0F * def.mobilityPitch) * LOOK_SCALE; rollDelta = clamp(x, 40.0F * def.mobilityRoll) * LOOK_SCALE; } @@ -165,6 +386,18 @@ protected void simulate() { private void updateThrottle(HelicopterDefinition def, boolean piloted) { if (piloted) { Vec3 motion = getDeltaMovement(); + if (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } if (!isHovering()) { if (input.throttleUp) { throttle = Math.min(1.0, throttle + 0.02 * def.throttleUpDown); @@ -314,5 +547,6 @@ protected void tickAnimation() { rotorAngleO = rotorAngle; float spool = 1.0F - visibleThrottle(); rotorAngle = (rotorAngle + (1.0F - spool * spool * spool) * def.rotorSpeed) % 360.0F; + tickBlades(def); } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 00000000..f52c4227 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 00000000..084f860b --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java @@ -0,0 +1,179 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A parachute with someone hanging under it, {@code MCH_EntityParachute}. It sinks at about two + * blocks a second, the rider steers it with the forward key, and it folds away a moment after it + * touches the ground or the water. + */ +public class ParachuteEntity extends Entity { + /** What the parachute looks like and how it opens, the original's model type. */ + public enum Kind { + /** From the parachute item: open from the start. */ + ITEM, + /** An ejection seat: the rider falls in the seat and the canopy opens once it drops. */ + EJECTION_SEAT, + /** Troops dropped from a rack or a cargo door: open from the start, no seat. */ + DROP + } + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(ParachuteEntity.class, EntityDataSerializers.INT); + private static final double MAX_SPEED = 0.35; + private static final double MIN_STEER = 0.07; + + private double steer = MIN_STEER; + private int onGroundCount; + /** Who boards on the next server tick; boarding in the same tick as the spawn can be lost. */ + private @Nullable Entity user; + + public ParachuteEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Opens a parachute above {@code user}, carrying over their speed, and puts them under it. + * Server only. + */ + public static ParachuteEntity open(Level level, Entity user, Kind kind) { + ParachuteEntity parachute = new ParachuteEntity(ModEntities.PARACHUTE.get(), level); + double lift = kind == Kind.ITEM ? 3.5 : 0.0; + parachute.setPos(user.getX(), user.getY() + lift, user.getZ()); + parachute.setYRot(user.getYRot()); + parachute.setDeltaMovement(user.getDeltaMovement()); + parachute.entityData.set(DATA_KIND, kind.ordinal()); + parachute.user = user; + user.resetFallDistance(); + level.addFreshEntity(parachute); + return parachute; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, Kind.ITEM.ordinal()); + } + + public Kind kind() { + Kind[] kinds = Kind.values(); + int i = entityData.get(DATA_KIND); + return i >= 0 && i < kinds.length ? kinds[i] : Kind.ITEM; + } + + /** An ejection seat only opens its canopy once it has started to fall. */ + public boolean isOpen() { + return kind() != Kind.EJECTION_SEAT || getDeltaMovement().y < -0.1; + } + + @Override + public void tick() { + super.tick(); + if (level().isClientSide()) { + return; + } + if (user != null) { + if (!user.isPassenger() && user.isAlive()) { + user.startRiding(this, true); + setYRot(user.getYRot()); + } + user = null; + } + Entity rider = getFirstPassenger(); + if (rider == null && tickCount > 20 || isInWater()) { + ejectPassengers(); + discard(); + return; + } + if (rider != null) { + rider.resetFallDistance(); + } + simulate(rider); + } + + /** {@code onUpdateServer}. */ + private void simulate(@Nullable Entity rider) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevSpeed = Math.sqrt(mx * mx + mz * mz); + boolean seatLaunch = kind() == Kind.EJECTION_SEAT && tickCount < 20; + my -= onGround() || seatLaunch ? 0.01 : 0.03; + + if (isOpen()) { + if (rider instanceof ServerPlayer player && player.zza > 0.0F) { + double yaw = Math.toRadians(rider.getYRot()); + mx += -Math.sin(yaw) * steer * 0.05; + mz += Math.cos(yaw) * steer * 0.05; + } + double speed = Math.sqrt(mx * mx + mz * mz); + if (speed > MAX_SPEED) { + mx *= MAX_SPEED / speed; + mz *= MAX_SPEED / speed; + speed = MAX_SPEED; + } + // Holding course builds up speed; letting go settles back to a slow drift. + if (speed > prevSpeed) { + steer = Math.min(steer + (MAX_SPEED - steer) / 35.0, MAX_SPEED); + } else { + steer = Math.max(steer - (steer - MIN_STEER) / 35.0, MIN_STEER); + } + } + + if (onGround() && ++onGroundCount > 5) { + ejectPassengers(); + discard(); + return; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + my *= seatLaunch ? 0.95 : 0.9; + double drag = isOpen() ? 0.95 : 0.99; + setDeltaMovement(mx * drag, my, mz * drag); + if (rider != null) { + setYRot(rider.getYRot()); + } + } + + /** + * The server moves the parachute; clients ease towards each position it sends over three + * ticks, as for the vehicles. Without this a slow descent steps from one update to the next. + */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + entityData.set(DATA_KIND, (input.contains("Kind") ? input.getInt("Kind") : Kind.ITEM.ordinal())); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putInt("Kind", kind().ordinal()); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 00000000..3023c3ae --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,96 @@ +package com.jnhprojects.mcheli.entity; + +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + @Override + protected void readAdditionalSaveData(CompoundTag input) { + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 00000000..52da80bc --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,330 @@ +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.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +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.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

The original kept the player at the station and had the drone read its pilot from there. + * Here the pilot takes the drone's reserved seat for as long as they fly it, hidden and unhurt, + * so the drone uses the same controls, cameras, HUD and weapons as every other vehicle. Leaving + * the drone, or losing it, puts the pilot back beside the station. + */ +public class UavStationEntity extends Entity { + public static final int STATION = 1; + public static final int PORTABLE = 2; + /** Further out than this, measured flat, the link breaks and the drone is lost, as in the original. */ + public static final double RANGE = 123.0; + /** The launch offset goes up to this far from the station along each axis. */ + public static final int MAX_OFFSET = 50; + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_OPEN = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_OFFSET = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BLOCK_POS); + private static final EntityDataAccessor DATA_LINKED = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + + private final SimpleContainer slot = new SimpleContainer(1); + private @Nullable UUID lastUav; + /** The lid of the portable controller, 0 shut to 1 open. */ + private float cover; + private float previousCover; + + public UavStationEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, (byte) STATION); + builder.define(DATA_OPEN, true); + builder.define(DATA_OFFSET, new BlockPos(12, 2, 12)); + builder.define(DATA_LINKED, false); + } + + public int kind() { + return entityData.get(DATA_KIND); + } + + public void setKind(int kind) { + entityData.set(DATA_KIND, (byte) (kind == PORTABLE ? PORTABLE : STATION)); + } + + public boolean isOpen() { + return entityData.get(DATA_OPEN); + } + + /** Where a launched drone appears, relative to the station. */ + public BlockPos offset() { + return entityData.get(DATA_OFFSET); + } + + public void setOffset(BlockPos offset) { + entityData.set(DATA_OFFSET, new BlockPos(Mth.clamp(offset.getX(), -MAX_OFFSET, MAX_OFFSET), + Mth.clamp(offset.getY(), -MAX_OFFSET, MAX_OFFSET), Mth.clamp(offset.getZ(), -MAX_OFFSET, MAX_OFFSET))); + } + + /** Whether a drone is flown from here right now; the screen lights up. */ + public boolean isLinked() { + return entityData.get(DATA_LINKED); + } + + public void setLinked(boolean linked) { + entityData.set(DATA_LINKED, linked); + } + + public SimpleContainer slot() { + return slot; + } + + public float cover(float partialTicks) { + return Mth.lerp(partialTicks, previousCover, cover); + } + + /** + * {@code initUavPostion}: the first launch offset is twelve blocks out on the diagonal the + * station faces, two up. + */ + public void initOffset() { + int quarter = (int) (Mth.positiveModulo(getYRot() + 45.0F, 360.0F) / 90.0F) % 4; + int x = quarter == 0 || quarter == 3 ? 12 : -12; + int z = quarter == 0 || quarter == 1 ? 12 : -12; + setOffset(new BlockPos(x, 2, z)); + } + + @Override + public void tick() { + super.tick(); + previousCover = cover; + cover = Mth.clamp(cover + (isOpen() ? 0.1F : -0.1F), 0.0F, 1.0F); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.03, 0.0); + move(MoverType.SELF, new Vec3(0.0, m.y, 0.0)); + setDeltaMovement(0.0, onGround() ? 0.0 : m.y * 0.96, 0.0); + + Entity user = getFirstPassenger(); + if (user instanceof ServerPlayer player && !slot.getItem(0).isEmpty()) { + launch(player, slot.getItem(0)); + } + } + + /** What the slot holds, if it is a vehicle at all. */ + public static @Nullable AircraftDefinition definitionOf(ItemStack stack) { + String name = AircraftItem.vehicleOf(stack); + return name != null ? MCHeli.content().any(name) : null; + } + + /** Whether this station can put up the vehicle in {@code stack}. */ + public boolean accepts(@Nullable AircraftDefinition def) { + return def != null && def.uav && (kind() != PORTABLE || def.smallUav); + } + + /** {@code handleItem}: puts up the drone in the slot and hands it to {@code player}. */ + private void launch(ServerPlayer player, ItemStack stack) { + AircraftDefinition def = definitionOf(stack); + if (!accepts(def) || !(level() instanceof ServerLevel server)) { + return; + } + if (ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return; + } + AircraftEntity uav = ModEntities.typeFor(def).create(server); + if (uav == null) { + return; + } + BlockPos o = offset(); + double y = Math.max(getY() + o.getY(), server.getMinBuildHeight() + 2.0); + uav.setType(def); + uav.moveTo(getX() + o.getX(), y, getZ() + o.getZ(), getYRot() - 180.0F, 0.0F); + if (!server.noCollision(uav, uav.getBoundingBox().deflate(0.1))) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.blocked"), true); + slot.setItem(0, ItemStack.EMPTY); + Containers.dropItemStack(server, player.getX(), player.getY(), player.getZ(), stack); + return; + } + slot.setItem(0, ItemStack.EMPTY); + uav.setFuelLevel(def.targetDrone ? uav.maxFuel() : (int) (uav.maxFuel() * 0.05F)); + server.addFreshEntity(uav); + if (!def.targetDrone) { + player.closeContainer(); + control(player, uav); + } + } + + /** {@code controlLastAircraft}: takes the last drone flown from here again. */ + public boolean continueControl(ServerPlayer player) { + AircraftEntity uav = lastUav(); + if (uav == null || getFirstPassenger() != player || !uav.canTakeRemotePilot()) { + return false; + } + player.closeContainer(); + return control(player, uav); + } + + public @Nullable AircraftEntity lastUav() { + if (lastUav == null || !(level() instanceof ServerLevel server)) { + return null; + } + Entity e = server.getEntity(lastUav); + return e instanceof AircraftEntity uav && uav.isAlive() && !uav.isDestroyed() && inRange(uav) ? uav : null; + } + + public boolean inRange(Entity uav) { + double dx = uav.getX() - getX(); + double dz = uav.getZ() - getZ(); + return dx * dx + dz * dz <= RANGE * RANGE; + } + + private boolean control(ServerPlayer player, AircraftEntity uav) { + player.stopRiding(); + if (!uav.takeRemotePilot(player, this)) { + return false; + } + lastUav = uav.getUUID(); + setLinked(true); + player.setYRot(uav.getYRot()); + return true; + } + + /** Where a pilot comes back to when the drone is left or lost: the seat in front of the station. */ + public Vec3 returnPosition() { + float yaw = getYRot() * Mth.DEG_TO_RAD; + return new Vec3(getX() - Mth.sin(yaw) * 0.9, getY(), getZ() + Mth.cos(yaw) * 0.9); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand) { + if (isVehicle()) { + return InteractionResult.PASS; + } + if (kind() == PORTABLE && player.isSecondaryUseActive()) { + if (!level().isClientSide()) { + entityData.set(DATA_OPEN, !isOpen()); + } + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive() || kind() == PORTABLE && !isOpen()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer server) { + player.setYRot(getYRot() - 180.0F); + if (player.startRiding(this)) { + server.openMenu(new SimpleMenuProvider((id, inventory, p) -> new UavStationMenu(id, inventory, this), + Component.translatable(kind() == PORTABLE ? "item.mcheli.uav_station2" : "item.mcheli.uav_station"))); + } + } + return InteractionResult.SUCCESS; + } + + /** The original sat the player down in front of the console, lower for the portable one. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + double y = kind() == PORTABLE ? -0.6 : 0.35; + return new Vec3(0.0, y, 0.9).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + @Override + public boolean hurt(DamageSource source, float damage) { + if (!(level() instanceof ServerLevel level)) { + return false; + } + if (isInvulnerableTo(source) || isRemoved() || damage <= 0.0F) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + AircraftEntity uav = lastUav(); + if (uav != null) { + uav.releaseRemotePilot(); + } + Containers.dropContents(level, blockPosition(), slot); + if (!creative) { + spawnAtLocation(item()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), source.getEntity() instanceof Player ? "hit" : "helidmg", 1.0F, 1.0F); + discard(); + return true; + } + + private Item item() { + return kind() == PORTABLE ? ModItems.UAV_CONTROLLER.get() : ModItems.UAV_STATION.get(); + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith() { + return true; + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(item()); + } + @Override + protected void readAdditionalSaveData(CompoundTag input) { + setKind((input.contains("Kind") ? input.getInt("Kind") : STATION)); + entityData.set(DATA_OPEN, (input.contains("Open") ? input.getBoolean("Open") : true)); + setOffset(new BlockPos((input.contains("UavX") ? input.getInt("UavX") : 12), (input.contains("UavY") ? input.getInt("UavY") : 2), (input.contains("UavZ") ? input.getInt("UavZ") : 12))); + lastUav = input.hasUUID("LastUav") ? input.getUUID("LastUav") : null; + slot.setItem(0, ItemStack.parseOptional(registryAccess(), input.getCompound("Item"))); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putInt("Kind", kind()); + output.putBoolean("Open", isOpen()); + BlockPos o = offset(); + output.putInt("UavX", o.getX()); + output.putInt("UavY", o.getY()); + output.putInt("UavZ", o.getZ()); + if (lastUav != null) { + output.putUUID("LastUav", lastUav); + } + if (!slot.getItem(0).isEmpty()) { + output.put("Item", slot.getItem(0).save(registryAccess())); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java new file mode 100644 index 00000000..5ea8dcd2 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java @@ -0,0 +1,144 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.supply.SupplyRules; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.nbt.CompoundTag; + +/** + * What a vehicle carries besides its crew, {@code MCH_AircraftInventory} and the container half of + * {@code W_EntityContainer}: the equipment slots of the vehicle screen (three fuel cans and a + * parachute per ejection seat) and the cargo chest of vehicles with an {@code InventorySize}. + * + *

The cargo chest always has room for the largest size, 54 slots, and the screen shows as many + * rows as the definition asks for. Items in rows a content reload took away are kept rather than + * lost, and still drop when the vehicle is destroyed. + */ +public final class VehicleInventory { + public static final int FUEL_SLOTS = 3; + public static final int PARACHUTE_SLOTS = 2; + public static final int EQUIPMENT_SLOTS = FUEL_SLOTS + PARACHUTE_SLOTS; + public static final int CARGO_SLOTS = 54; + /** How far from the vehicle's centre a player outside may keep a screen open, past its width. */ + private static final double REACH = 12.0; + + private final AircraftEntity vehicle; + private final SimpleContainer equipment = new GuardedContainer(EQUIPMENT_SLOTS); + private final SimpleContainer cargo = new GuardedContainer(CARGO_SLOTS); + + VehicleInventory(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + public SimpleContainer equipment() { + return equipment; + } + + public SimpleContainer cargo() { + return cargo; + } + + /** {@code isUseableByPlayer}: the crew, or someone standing next to the vehicle. */ + public boolean stillValid(Player player) { + if (vehicle.isRemoved() || vehicle.definition() == null) { + return false; + } + if (player.getVehicle() == vehicle) { + return true; + } + double reach = REACH + vehicle.definition().bodyWidth / 2.0; + return player.distanceToSqr(vehicle) <= reach * reach; + } + + // Fuel + + /** + * {@code updateFuel}: every half second, while the vehicle stands on something, each fuel can + * in a fuel slot pours up to 100 units into the tank. Server only. + * + * @return whether any fuel went in + */ + boolean pourFuelCans(int ticks) { + if (ticks % SupplyRules.FUEL_SLOT_INTERVAL != 0 || !vehicle.canSupply()) { + return false; + } + boolean poured = false; + for (int slot = 0; slot < FUEL_SLOTS; slot++) { + ItemStack can = equipment.getItem(slot); + if (!(can.getItem() instanceof FuelItem)) { + continue; + } + int amount = SupplyRules.fuelFromCan(vehicle.maxFuel() - vehicle.fuel(), FuelItem.contents(can)); + if (amount > 0) { + vehicle.refuel(amount); + can.setDamageValue(can.getDamageValue() + amount); + poured = true; + } + } + if (poured) { + equipment.setChanged(); + } + return poured; + } + + // Parachutes + + /** Parachute slots the vehicle screen offers: one per ejection seat. */ + public static int parachuteSlots(AircraftDefinition def) { + return Math.min(def.ejectionSeats(), PARACHUTE_SLOTS); + } + + /** + * {@code consumeParachute}: takes one parachute out of the parachute slots for a crew member + * who bails out. + * + * @return false when there is none left + */ + boolean takeParachute() { + for (int slot = FUEL_SLOTS; slot < EQUIPMENT_SLOTS; slot++) { + if (equipment.getItem(slot).getItem() instanceof ParachuteItem) { + equipment.removeItem(slot, 1); + return true; + } + } + return false; + } + + // Saving and dropping + + void save(CompoundTag output) { + output.put("Equipment", ContainerHelper.saveAllItems(new CompoundTag(), equipment.getItems(), false, vehicle.registryAccess())); + output.put("Cargo", ContainerHelper.saveAllItems(new CompoundTag(), cargo.getItems(), false, vehicle.registryAccess())); + } + + void load(CompoundTag input) { + equipment.clearContent(); + cargo.clearContent(); + ContainerHelper.loadAllItems(input.getCompound("Equipment"), equipment.getItems(), vehicle.registryAccess()); + ContainerHelper.loadAllItems(input.getCompound("Cargo"), cargo.getItems(), vehicle.registryAccess()); + } + + /** {@code setDead}: everything aboard falls out where the vehicle was. Server only. */ + void dropAll() { + Containers.dropContents(vehicle.level(), vehicle, equipment); + Containers.dropContents(vehicle.level(), vehicle, cargo); + } + + /** A container that only stays open for players {@link #stillValid(Player)} allows. */ + private final class GuardedContainer extends SimpleContainer { + GuardedContainer(int size) { + super(size); + } + + @Override + public boolean stillValid(Player player) { + return VehicleInventory.this.stillValid(player); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java new file mode 100644 index 00000000..46ed88a8 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.ParticleSplash; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * The spray, dust and exhaust a vehicle throws up, all client side: + *

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} 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 index 3ffc508a..a572358b 100644 --- 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 @@ -48,6 +48,9 @@ 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); + /** Who fired, for clients: their rounds must not stop on their own vehicle. */ + private static final EntityDataAccessor DATA_SHOOTER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_USER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); /** Beyond 5820 blocks from whoever fired it, a projectile is dropped. */ private static final double MAX_RANGE_SQ = 3.38724E7; @@ -66,6 +69,8 @@ public abstract class ProjectileEntity extends Entity { public double acceleration = 1.0; public double accelerationFactor = 1.0; private int age; + /** Client only: the round hit something here and waits, hidden, for the server to remove it. */ + private boolean spent; public Vec3 prevMotion = Vec3.ZERO; /** Position two ticks ago, used to spread trail particles over the distance covered. */ @@ -83,6 +88,8 @@ protected void defineSynchedData(SynchedEntityData.Builder builder) { builder.define(DATA_WEAPON, ""); builder.define(DATA_TARGET, 0); builder.define(DATA_BOMBLET, false); + builder.define(DATA_SHOOTER, 0); + builder.define(DATA_USER, 0); } /** @@ -153,8 +160,7 @@ public void setParameters(WeaponDefinition def, @Nullable Entity shooter, @Nulla explosionPowerInWater = def.explosionInWater; power = def.power; piercing = def.piercing; - this.shooter = shooter; - this.user = user; + setOwners(shooter, user); } /** The same, inherited from a parent projectile, for bomblets. */ @@ -163,13 +169,14 @@ public void setParameters(ProjectileEntity parent) { explosionPowerInWater = parent.explosionPowerInWater; power = parent.power; piercing = parent.piercing; - shooter = parent.shooter; - user = parent.user; + setOwners(parent.shooter, parent.user); } public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { this.shooter = shooter; this.user = user; + entityData.set(DATA_SHOOTER, shooter != null ? shooter.getId() : 0); + entityData.set(DATA_USER, user != null ? user.getId() : 0); } public @Nullable Entity user() { @@ -222,6 +229,9 @@ public float gravityInWater() { @Override public void tick() { + if (spent) { + return; + } if (level().isClientSide() && age == 0) { int targetId = entityData.get(DATA_TARGET); if (targetId > 0) { @@ -535,8 +545,21 @@ protected void checkCollisions() { return best != null ? new Impact(bestAt, best, null, Direction.UP) : null; } + /** Clients only learn who fired through synched data; look them up once it has arrived. */ + private void resolveOwners() { + if (shooter == null && entityData.get(DATA_SHOOTER) > 0) { + shooter = level().getEntity(entityData.get(DATA_SHOOTER)); + } + if (user == null && entityData.get(DATA_USER) > 0) { + user = level().getEntity(entityData.get(DATA_USER)); + } + } + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ public boolean canHit(Entity entity) { + if (level().isClientSide()) { + resolveOwners(); + } if (!entity.isPickable() || entity == user || entity == shooter) { return false; } @@ -603,6 +626,13 @@ protected void impact(Impact hit, float damageFactor) { if (weapon != null && (weapon.explosion == 0 || weapon.modeNum >= 2) && hit.block() != null) { debris(hit); } + if (piercing <= 0) { + // The server decides what the hit does; until its removal arrives the round stays + // where it struck instead of flying on through the hull. + spent = true; + setPos(hit.location()); + setDeltaMovement(Vec3.ZERO); + } return; } if (weapon == null) { @@ -726,6 +756,11 @@ public boolean hurt(DamageSource source, float damage) { return false; } + /** Client only: whether the round already hit something and should no longer be drawn. */ + public boolean isSpent() { + return spent; + } + @Override public boolean isPickable() { return true; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 00000000..1f198726 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,191 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +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.util.FastColor; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = (FastColor.ARGB32.red(color) / 255.0F) * 0.9F + random.nextFloat() * 0.1F; + float g = (FastColor.ARGB32.green(color) / 255.0F) * 0.9F + random.nextFloat() * 0.1F; + float b = (FastColor.ARGB32.blue(color) / 255.0F) * 0.9F + random.nextFloat() * 0.1F; + if (FastColor.ARGB32.red(color) == FastColor.ARGB32.green(color)) { + g = r; + } + if (FastColor.ARGB32.red(color) == FastColor.ARGB32.blue(color)) { + b = r; + } + if (FastColor.ARGB32.green(color) == FastColor.ARGB32.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(FastColor.ARGB32.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + public boolean hurt(DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} 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 index f7258aa3..7eacf467 100644 --- 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 @@ -4,10 +4,12 @@ import com.jnhprojects.mcheli.ServerConfig; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackManifest; import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; import net.minecraft.core.component.DataComponents; import net.minecraft.network.chat.Component; import net.minecraft.stats.Stats; @@ -16,6 +18,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.component.CustomModelData; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.Level; @@ -25,6 +28,8 @@ import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; +import java.util.List; + /** * Places a vehicle, the same way a boat is placed. * @@ -83,6 +88,21 @@ public Component getName(ItemStack stack) { return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); } + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, List out, TooltipFlag flag) { + super.appendHoverText(stack, context, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.add((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + @Override public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { ItemStack stack = player.getItemInHand(hand); diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 00000000..0f89341b --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.removed"), true); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.released"), true); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance)), true); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.no_vehicle"), true); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.displayClientMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 00000000..71b3ed0a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * The fuel can, {@code MCH_ItemFuel}. It holds 600 units; the durability bar shows how much is + * left. Using it refills it from the coal and charcoal in the inventory, as in the original. + * + *

A can in a fuel slot of the vehicle screen empties into the tank while the vehicle stands on + * something, as in the original. Not in the original: right-clicking a vehicle with the can pours + * up to 100 units at once, which saves opening the screen. + */ +public class FuelItem extends Item { + /** Units a full can holds, the original's maximum damage. */ + public static final int CAPACITY = 600; + /** Units poured into a vehicle per click: what the original moved per fuel slot per update. */ + private static final int POUR = 100; + private static final int PER_COAL = 100; + private static final int PER_CHARCOAL = 75; + + public FuelItem(Properties properties) { + super(properties.durability(CAPACITY)); + } + + public static int contents(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + /** Fills a vehicle's tank from the can. Server only. */ + public static InteractionResult pour(ItemStack stack, AircraftEntity vehicle, Player player) { + int added = vehicle.refuel(player.getAbilities().instabuild ? POUR : Math.min(contents(stack), POUR)); + if (added <= 0) { + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) { + stack.setDamageValue(stack.getDamageValue() + added); + } + vehicle.level().playSound(null, vehicle.getX(), vehicle.getY(), vehicle.getZ(), SoundEvents.BUCKET_EMPTY, SoundSource.NEUTRAL, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } + + /** Charcoal first, then coal, each one worth part of the can, until it is full. */ + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + if (!stack.isDamaged() || player.getAbilities().instabuild) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + if (!level.isClientSide()) { + refill(stack, player.getInventory(), Items.CHARCOAL, PER_CHARCOAL); + refill(stack, player.getInventory(), Items.COAL, PER_COAL); + } + return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide()); + } + + private static void refill(ItemStack can, Inventory inventory, Item coal, int worth) { + for (int slot = 0; slot < inventory.getContainerSize() && can.isDamaged(); slot++) { + ItemStack item = inventory.getItem(slot); + while (item.is(coal) && !item.isEmpty() && can.isDamaged()) { + can.setDamageValue(Math.max(can.getDamageValue() - worth, 0)); + item.shrink(1); + } + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 00000000..1829b7ee --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + player.startUsingItem(hand); + return InteractionResultHolder.consume(player.getItemInHand(hand)); + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.BOW; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 00000000..9f19eb4f --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java @@ -0,0 +1,28 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.level.Level; + +/** The parachute, {@code MCH_ItemParachute}: used in mid-air, it opens above the player. */ +public class ParachuteItem extends Item { + public ParachuteItem(Properties properties) { + super(properties.stacksTo(1)); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (player.isPassenger() || player.onGround()) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + if (!level.isClientSide()) { + ParachuteEntity.open(level, player, ParachuteEntity.Kind.ITEM); + player.getItemInHand(hand).consume(1, player); + } + return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide()); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 00000000..3b68bcca --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.moveTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 00000000..897d51cd --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + player.startUsingItem(hand); + return InteractionResultHolder.consume(player.getItemInHand(hand)); + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.SPYGLASS; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 00000000..8c98a126 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,124 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.UseAnim; +import net.minecraft.world.item.component.CustomModelData; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + MCHeli.legacyContent().iconIndex(def.name()) + .ifPresent(index -> stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(index))); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResultHolder use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResultHolder.fail(player.getItemInHand(hand)); + } + player.startUsingItem(hand); + return InteractionResultHolder.consume(player.getItemInHand(hand)); + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public UseAnim getUseAnimation(ItemStack stack) { + return UseAnim.BOW; + } + + @Override + public void releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return; + } + int drawn = Math.max(getUseDuration(stack, entity) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 00000000..7e3bc1c8 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 00000000..a9ed1555 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * The wrench, {@code MCH_ItemWrench}. Right-clicking a damaged vehicle repairs 10 hit points, once + * a second, at one point of the wrench's durability. Sneaking switches the vehicle to its next + * paint scheme. + * + *

The original repaired while the use key was held; a click per repair works the same with + * today's item use, which stops as soon as the cursor leaves the hull. + */ +public class WrenchItem extends Item { + /** An iron tool's durability, the original's tool material. */ + private static final int DURABILITY = 250; + private static final int REPAIR = 10; + private static final int COOLDOWN = 20; + + public WrenchItem(Properties properties) { + super(properties.durability(DURABILITY)); + } + + /** Server only. */ + public static InteractionResult useOn(ItemStack stack, AircraftEntity vehicle, Player player, InteractionHand hand) { + if (player.isSecondaryUseActive()) { + return vehicle.nextTexture() ? InteractionResult.SUCCESS : InteractionResult.FAIL; + } + if (player.getCooldowns().isOnCooldown(stack.getItem()) || !vehicle.repair(REPAIR)) { + return InteractionResult.FAIL; + } + player.getCooldowns().addCooldown(stack.getItem(), COOLDOWN); + stack.hurtAndBreak(1, player, LivingEntity.getSlotForHand(hand)); + ModSounds.playAt(vehicle, "wrench", 1.0F, 0.9F + vehicle.getRandom().nextFloat() * 0.2F); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 00000000..022c1813 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,155 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes and asks for one by its id; the server finds the + * recipe, takes the ingredients from the inventory and puts the result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addPlayerSlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, ResourceLocation id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeHolder holder = player.level().getServer().getRecipeManager().byKey(id).orElse(null); + if (holder == null || !id.getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + // A shaped recipe lists its empty grid slots too. + List ingredients = recipe.getIngredients().stream().filter(ingredient -> !ingredient.isEmpty()).toList(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.assemble(CraftingInput.EMPTY, player.registryAccess())); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } + + /** The player's inventory and, 58 below its top, the hotbar. */ + private void addPlayerSlots(Inventory inventory, int x, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, x + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, x + column * 18, y + 58)); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 00000000..6e4b7a0c --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,166 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addPlayerSlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } + + /** The player's inventory and, 58 below its top, the hotbar. */ + private void addPlayerSlots(Inventory inventory, int x, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, x + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, x + column * 18, y + 58)); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java new file mode 100644 index 00000000..d69c63ec --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java @@ -0,0 +1,229 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGuiContainer}: fuel slots, a parachute slot per ejection + * seat, reloading the weapons from the crew member's own items and the way into the cargo chest. + * + *

Only crew open it, so the client finds the vehicle as the one its player rides. The weapon + * rounds and which weapons can be reloaded right now come from the server through data slots, + * since a client only hears about the ammunition of the seat it sits in. + */ +public class VehicleMenu extends AbstractContainerMenu { + /** Weapons the screen can show; more than any vehicle in the original has. */ + public static final int MAX_WEAPONS = 32; + /** Button ids from zero up reload that weapon; this one opens the cargo chest. */ + public static final int BUTTON_CARGO = 1000; + + public static final int FUEL_X = 8; + public static final int FUEL_Y = 30; + public static final int PARACHUTE_X = 8; + public static final int PARACHUTE_Y = 66; + public static final int INVENTORY_Y = 102; + + private static final int DATA_RELOADABLE = MAX_WEAPONS; + private static final int DATA_SIZE = MAX_WEAPONS + 2; + + private final @Nullable AircraftEntity vehicle; + private final Container equipment; + private final ContainerData data; + private final int parachuteSlots; + private boolean supplied; + + /** The client side, from the open screen packet. */ + public VehicleMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof AircraftEntity v ? v : null, + new SimpleContainer(VehicleInventory.EQUIPMENT_SLOTS), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public VehicleMenu(int containerId, Inventory inventory, AircraftEntity vehicle) { + this(containerId, inventory, vehicle, vehicle.inventory().equipment(), new ServerData(vehicle, inventory.player)); + } + + private VehicleMenu(int containerId, Inventory inventory, @Nullable AircraftEntity vehicle, Container equipment, ContainerData data) { + super(ModMenus.VEHICLE.get(), containerId); + this.vehicle = vehicle; + this.equipment = equipment; + this.data = data; + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + parachuteSlots = def != null ? VehicleInventory.parachuteSlots(def) : 0; + checkContainerSize(equipment, VehicleInventory.EQUIPMENT_SLOTS); + + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + addSlot(new Slot(equipment, i, FUEL_X + i * 18, FUEL_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof FuelItem; + } + }); + } + for (int i = 0; i < VehicleInventory.PARACHUTE_SLOTS; i++) { + boolean active = i < parachuteSlots; + addSlot(new Slot(equipment, VehicleInventory.FUEL_SLOTS + i, PARACHUTE_X + i * 18, PARACHUTE_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return active && stack.getItem() instanceof ParachuteItem; + } + + @Override + public boolean isActive() { + return active; + } + }); + } + addPlayerSlots(inventory, 8, INVENTORY_Y); + addDataSlots(data); + } + + public @Nullable AircraftEntity vehicle() { + return vehicle; + } + + /** Parachute slots in use: one per ejection seat. */ + public int parachuteSlots() { + return parachuteSlots; + } + + /** Magazine and reserve of a weapon together, as the server last reported them. */ + public int rounds(int weapon) { + return weapon >= 0 && weapon < MAX_WEAPONS ? data.get(weapon) : 0; + } + + /** Whether the server would reload this weapon now: the vehicle is down and the items are there. */ + public boolean canReload(int weapon) { + if (weapon < 0 || weapon >= MAX_WEAPONS) { + return false; + } + int bits = (data.get(DATA_RELOADABLE) & 0xFFFF) | (data.get(DATA_RELOADABLE + 1) & 0xFFFF) << 16; + return (bits & 1 << weapon) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (vehicle == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CARGO) { + return vehicle.openCargo(server); + } + if (id >= 0 && id < MAX_WEAPONS && Resupply.supply(server, vehicle, id)) { + supplied = true; + return true; + } + return false; + } + + @Override + public boolean stillValid(Player player) { + return vehicle != null && equipment.stillValid(player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + int equipmentEnd = VehicleInventory.EQUIPMENT_SLOTS; + if (index < equipmentEnd) { + if (!moveItemStackTo(stack, equipmentEnd, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof FuelItem) { + if (!moveItemStackTo(stack, 0, VehicleInventory.FUEL_SLOTS, false)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof ParachuteItem && parachuteSlots > 0) { + int first = VehicleInventory.FUEL_SLOTS; + if (!moveItemStackTo(stack, first, first + parachuteSlots, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** As in the original, closing the screen after a resupply loads every magazine from its reserve. */ + @Override + public void removed(Player player) { + super.removed(player); + if (supplied && vehicle != null && vehicle.weapons() != null && !player.level().isClientSide()) { + vehicle.weapons().reloadAll(); + } + } + + /** Rounds per weapon and the reload bits, worked out on the server whenever the menu syncs. */ + private record ServerData(AircraftEntity vehicle, Player player) implements ContainerData { + @Override + public int get(int index) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons == null) { + return 0; + } + if (index < MAX_WEAPONS) { + WeaponSet set = weapons.set(index); + // Data slots travel as shorts. + return set != null ? Math.min(set.ammo() + set.restAmmo(), Short.MAX_VALUE) : 0; + } + int bits = 0; + if (player instanceof ServerPlayer server) { + for (int i = 0; i < Math.min(weapons.sets().size(), MAX_WEAPONS); i++) { + if (Resupply.canSupply(server, vehicle, i)) { + bits |= 1 << i; + } + } + } + return index == DATA_RELOADABLE ? bits & 0xFFFF : bits >>> 16; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } + + /** The player's inventory and, 58 below its top, the hotbar. */ + private void addPlayerSlots(Inventory inventory, int x, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, x + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, x + column * 18, y + 58)); + } + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java index 8e7136fa..1b565e21 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -1,8 +1,8 @@ package com.jnhprojects.mcheli.network; +import com.jnhprojects.mcheli.ModSounds; 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; @@ -44,10 +44,20 @@ public enum Action { 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, + /** Open the vehicle screen: fuel and parachute slots, reloading, the way to the cargo chest. */ + VEHICLE_SCREEN, /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ - GUNNER_MODE + GUNNER_MODE, + /** Turn night vision on for the sender, in vehicles with {@code EnableNightVision}. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT } public static void encode(CrewActionPacket msg, FriendlyByteBuf buf) { @@ -131,7 +141,19 @@ public static void handle(CrewActionPacket msg, ServerPlayer sender) { weapons.flares.use(); } } - case RESUPPLY -> Resupply.fromInventory(sender, vehicle); + case VEHICLE_SCREEN -> vehicle.openScreen(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } } } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 00000000..02c394df --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +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.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe with this id. + */ +public record DraftingTablePacket(ResourceLocation recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + buf.writeResourceLocation(msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(buf.readResourceLocation()); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 00000000..76090f2e --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +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 request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket 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 equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 00000000..4306edfa --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,118 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.serverLevel(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level) + : ModEntities.AA_MISSILE.get().create(level); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 00000000..1de0bfc3 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +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 client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @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 index de59563d..74ee6247 100644 --- 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 @@ -21,23 +21,32 @@ */ public final class ModNetwork { /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - public static final int PROTOCOL_VERSION = 6; + public static final int PROTOCOL_VERSION = 11; /** 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<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), + new Serverbound<>(TestFlightPacket.TYPE, TestFlightPacket.STREAM_CODEC, TestFlightPacket::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)); + new Serverbound<>(PackRequestPacket.TYPE, PackRequestPacket.STREAM_CODEC, PackRequestPacket::handle), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); /** Server to client. Handlers touch client classes, so they only ever run on a client. */ public static final List> CLIENTBOUND = List.of( 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)); + new Clientbound<>(PackDataPacket.TYPE, PackDataPacket.STREAM_CODEC, PackDataPacket::handle), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::handle)); private ModNetwork() { } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..0a01b93a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

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

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.ioPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-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 index 2a12ac25..e522f525 100644 --- 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 @@ -43,10 +43,12 @@ public static void onLogin(ServerPlayer player) { if (seesProblems(player)) { MCHeli.packs().reportProblems(player::sendSystemMessage); } + PackSubmissions.onLogin(player); } public static void onLogout(ServerPlayer player) { TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); } /** The packs the server uses now, as the client should load them. */ diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 00000000..1ee72286 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +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.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 00000000..14b2db42 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +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 button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java new file mode 100644 index 00000000..db12a390 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java @@ -0,0 +1,34 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.workshop.TestFlight; +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; + +/** The workshop's Test flight button: fly the vehicle just saved. The server checks who is asking. */ +public record TestFlightPacket(String vehicle) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("test_flight"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TestFlightPacket::encode, TestFlightPacket::decode); + private static final int MAX_NAME = 64; + + public static void encode(TestFlightPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.vehicle, MAX_NAME); + } + + public static TestFlightPacket decode(FriendlyByteBuf buf) { + return new TestFlightPacket(buf.readUtf(MAX_NAME)); + } + + public static void handle(TestFlightPacket msg, ServerPlayer sender) { + if (sender != null) { + TestFlight.start(sender, msg.vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 00000000..b8151034 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.particle; + +import net.minecraft.util.ExtraCodecs; +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; + +/** + * A puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + Codec.INT.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 00000000..28270640 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,21 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.entity.BlockEntityType; + + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> BlockEntityType.Builder.of(DraftingTableBlockEntity::new, ModBlocks.DRAFTING_TABLE.get()).build(null)); + + private ModBlockEntities() { + } + + /** 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/ModBlocks.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 00000000..c833e532 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of() + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** 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/ModCreativeTabs.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java index 8b24af05..870aa8b8 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ThrowableItem; import com.jnhprojects.mcheli.platform.Platform; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; @@ -16,8 +17,9 @@ 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. + * One tab per vehicle kind, listing a stack for each loaded definition of that kind, and one for + * the equipment and throwables. Those lists are 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", @@ -25,6 +27,15 @@ public final class ModCreativeTabs { 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)); + public static final Registered EQUIPMENT = Registration.register(Registries.CREATIVE_MODE_TAB, "equipment", + key -> Platform.get().creativeTab(key(VEHICLES)) + .title(Component.translatable("itemGroup.mcheli.equipment")) + .icon(() -> new ItemStack(ModItems.WRENCH.get())) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) + .build()); private ModCreativeTabs() { } @@ -52,7 +63,7 @@ private static Registered register(String name, String kind, St /** 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()); + return List.of(HELICOPTERS.get(), PLANES.get(), TANKS.get(), VEHICLES.get(), EQUIPMENT.get()); } /** Declares the entries; the loader module registers them. */ diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java index 08b564eb..377a5783 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -18,6 +18,13 @@ public final class ModDataComponents { .networkSynchronized(ByteBufCodecs.STRING_UTF8) .build()); + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + key -> DataComponentType.builder() + .persistent(Codec.STRING) + .networkSynchronized(ByteBufCodecs.STRING_UTF8) + .build()); + private ModDataComponents() { } 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 index 19441e8c..bc6526db 100644 --- 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 @@ -5,10 +5,16 @@ 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.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; import com.jnhprojects.mcheli.entity.GroundVehicleEntity; import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.ParachuteEntity; import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; import com.jnhprojects.mcheli.entity.projectile.A10Entity; import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; @@ -21,6 +27,7 @@ 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.ThrowableEntity; import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; @@ -65,6 +72,61 @@ public final class ModEntities { .noSave() .build(key.location().toString())); + public static final Registered> PARACHUTE = Registration.register(Registries.ENTITY_TYPE, "parachute", key -> EntityType.Builder + .of(ParachuteEntity::new, MobCategory.MISC) + .sized(1.5F, 0.6F) + .clientTrackingRange(10) + .updateInterval(1) + .build(key.location().toString())); + + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key.location().toString())); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key.location().toString())); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key.location().toString())); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key.location().toString())); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key.location().toString())); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key.location().toString())); + private ModEntities() { } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java index 7eec8784..ca2691f2 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -3,18 +3,31 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.entity.UavStationEntity; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; +import com.jnhprojects.mcheli.item.WrenchItem; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; import net.minecraft.world.item.Item; +import java.util.List; 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. + * items, one per vehicle, are kept for the content present at startup so old worlds keep them. The + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. */ public final class ModItems { public static final Registered HELICOPTER = kind("helicopter"); @@ -22,6 +35,34 @@ public final class ModItems { public static final Registered TANK = kind("tank"); public static final Registered VEHICLE = kind("vehicle"); + public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties())); + public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties())); + public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties())); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties())); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties())); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties())); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties())); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties())); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties())); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); + private static final Map> BY_KIND = Map.of("helicopter", HELICOPTER, "plane", PLANE, "tank", TANK, "vehicle", VEHICLE); @@ -40,7 +81,7 @@ 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)) { + if (!def.pack().equals(ContentSource.ORIGINAL) || BY_KIND.containsKey(name) || EQUIPMENT.stream().anyMatch(e -> e.id().getPath().equals(name))) { // 1.0.0 could not have had such an item either: it would have clashed the same way. continue; } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java new file mode 100644 index 00000000..0dac0052 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.MenuType; + +/** Our screens with slots. The cargo chest uses vanilla's chest menus and needs no entry here. */ +public final class ModMenus { + public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", + key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); + + private ModMenus() { + } + + /** 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/ModParticles.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java index ce9a1da4..4ed53acd 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.registry; import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; import com.mojang.serialization.MapCodec; @@ -24,6 +25,20 @@ public StreamCodec streamC } }); + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + private ModParticles() { } diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 00000000..4d869373 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 00000000..ae6a59de --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,113 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.portal.DimensionTransition; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + /** The colours a team can have. */ + public static final List COLORS = Arrays.stream(ChatFormatting.values()).filter(ChatFormatting::isColor).toList(); + + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.hasPermissions(Commands.LEVEL_GAMEMASTERS); + } + + /** Switches the server's PVP setting and says which way it went. There is no game rule for it here, so it lasts until a restart. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !server.isPvpAllowed(); + server.setPvpAllowed(pvp); + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.displayClientMessage(Component.translatable("message.mcheli.team.no_teams"), true); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.displayClientMessage(Component.translatable("message.mcheli.team.bad_name", trimmed), true); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(COLORS.get(Math.floorMod(color, COLORS.size()))); + team.setAllowFriendlyFire(friendlyFire); + player.displayClientMessage(Component.translatable("message.mcheli.team.created", trimmed), true); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + target.changeDimension(target.findRespawnPositionAndUseSpawnBlock(false, DimensionTransition.DO_NOTHING)); + } + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.displayClientMessage(Component.translatable("message.mcheli.team.destroyed", count), true); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 00000000..81763693 --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,124 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + 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); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java index 44df1ac2..533f5fba 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -10,30 +10,35 @@ 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. + * Resupplying a weapon from a crew member's own items, the {@code supplyAmmo} path of the + * original's vehicle screen. One supply costs the weapon's {@code Item} lines and adds + * {@code SuppliedNum} rounds; it only works while the vehicle stands on something. */ public final class Resupply { private Resupply() { } - public static boolean fromInventory(ServerPlayer player, AircraftEntity vehicle) { + /** {@code canPlayerSupplyAmmo}: whether {@link #supply} would do anything right now. */ + public static boolean canSupply(ServerPlayer player, AircraftEntity vehicle, int weapon) { VehicleWeapons weapons = vehicle.weapons(); - int seat = vehicle.seatOf(player); - WeaponSet set = weapons != null ? weapons.current(seat) : null; - if (set == null || vehicle.isDestroyed()) { + WeaponSet set = weapons != null ? weapons.set(weapon) : null; + if (set == null || vehicle.isDestroyed() || player.getVehicle() != vehicle || !vehicle.canSupply()) { return false; } - WeaponDefinition w = set.definition(); if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { return false; } + return player.getAbilities().instabuild || canPay(player.getInventory(), set.definition()); + } + + /** Pays for and adds one supply of {@code weapon}. Server only. */ + public static boolean supply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + if (!canSupply(player, vehicle, weapon)) { + return false; + } + WeaponSet set = vehicle.weapons().set(weapon); if (!player.getAbilities().instabuild) { - if (!canPay(player.getInventory(), w)) { - return false; - } - pay(player.getInventory(), w); + pay(player.getInventory(), set.definition()); } set.supply(); if (set.ammo() <= 0) { 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 index 5a536bab..f7b6e969 100644 --- 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 @@ -8,7 +8,6 @@ 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; @@ -26,10 +25,7 @@ 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; @@ -41,6 +37,9 @@ import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; +import java.util.ArrayList; +import java.util.List; + /** * 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. @@ -93,14 +92,19 @@ private static Vec3 forward(float yaw, float pitch) { } private static void sound(WeaponSet set, Entity at) { - sound(set, at, set.definition().sound); + sound(set, at, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; } private static void sound(WeaponSet set, Entity at, String name) { if (set.soundAllowed) { WeaponDefinition w = set.definition(); float rnd = w.soundPitchRandom; - ModSounds.playAt(at, name, w.soundVolume, w.soundPitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); } } @@ -335,20 +339,24 @@ 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"); + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? soundName(set) : "ng"); return true; } - boolean spotted = false; + List spotted = new ArrayList<>(); 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; + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); } - sound(set, shot.shooter, spotted ? w.sound : "ng"); + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "ng"); return true; } @@ -383,12 +391,10 @@ private static boolean cas(ServerLevel level, WeaponSet set, Shot shot) { 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.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); set.casUser = shot.user; set.casShooter = shot.shooter; - set.casStartTick = 50; + set.casStartTick = CasStrike.FIRST_PASS; set.casCount = 0; if (shot.user instanceof ServerPlayer player) { ModSounds.playTo(player, set.definition().sound, 1.0F, 1.0F); @@ -402,55 +408,10 @@ static void tick(AircraftEntity vehicle, WeaponSet set) { || !(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); + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); 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/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 00000000..10167f4a --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +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 java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.serverLevel(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.serverLevel(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} 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 index a5dc19b2..02f4c39b 100644 --- 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 @@ -70,7 +70,9 @@ public VehicleWeapons(AircraftEntity vehicle, AircraftDefinition def) { for (WeaponGroup group : def.weaponGroups) { WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); if (weapon != null && !group.mounts().isEmpty()) { - sets.add(new WeaponSet(weapon, group.mounts(), clientSide)); + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); } } selected = new int[def.seatCount()]; diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java index b358a4c9..3f539030 100644 --- a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -1,11 +1,17 @@ package com.jnhprojects.mcheli.weapon; import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import com.jnhprojects.mcheli.content.weapon.WeaponType; import net.minecraft.util.Mth; import net.minecraft.util.RandomSource; import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +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.List; @@ -51,6 +57,8 @@ public interface Trigger { public int soundWait; /** Whether the shot being fired may play its sound, see {@code SoundDelay}. */ public boolean soundAllowed = true; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; /** Aim relative to the hull, following the crew member who operates the weapon. */ public float rotationYaw; @@ -276,6 +284,57 @@ public void waitForGroup(boolean reload) { } } + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + // Firing /** diff --git a/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java new file mode 100644 index 00000000..4fe57c2c --- /dev/null +++ b/minecraft-1.21.1/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.phys.Vec3; + +/** + * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server + * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. + */ +public final class TestFlight { + /** How far up to look for room when the vehicle does not fit where the player stands. */ + private static final int MAX_RISE = 48; + + private TestFlight() { + } + + public static void start(ServerPlayer player, String vehicle) { + MinecraftServer server = player.level().getServer(); + if (!server.isSingleplayerOwner(player.getGameProfile())) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_owner_only"), true); + return; + } + if (player.isPassenger()) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_riding"), true); + return; + } + ContentReload.reload(server); + AircraftDefinition def = MCHeli.content().any(vehicle); + if (def == null) { + player.sendSystemMessage(Component.translatable("message.mcheli.workshop.test_flight_no_vehicle", vehicle)); + MCHeli.packs().reportProblems(player::sendSystemMessage); + return; + } + ServerLevel level = player.serverLevel(); + AircraftEntity aircraft = ModEntities.typeFor(def).create(level); + if (aircraft == null) { + return; + } + aircraft.setType(def); + Vec3 start = player.position(); + for (int rise = 0; rise <= MAX_RISE; rise++) { + aircraft.moveTo(start.x, start.y + rise, start.z, player.getYRot(), 0.0F); + if (level.noCollision(aircraft, aircraft.getBoundingBox())) { + break; + } + } + aircraft.startTestFlight(start); + level.addFreshEntity(aircraft); + player.startRiding(aircraft, true); + } +} diff --git a/minecraft-1.21.1/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-1.21.1/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 00000000..82992af9 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-1.21.1/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-1.21.1/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 00000000..8dfa5bcc --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-1.21.1/src/main/resources/assets/mcheli/models/item/drafting_table.json b/minecraft-1.21.1/src/main/resources/assets/mcheli/models/item/drafting_table.json new file mode 100644 index 00000000..355ce4a7 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/mcheli/models/item/drafting_table.json @@ -0,0 +1,3 @@ +{ + "parent": "mcheli:block/drafting_table" +} diff --git a/minecraft-1.21.1/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-1.21.1/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 00000000..977381be --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-1.21.1/src/main/resources/assets/mcheli/shaders/post/thermal.json b/minecraft-1.21.1/src/main/resources/assets/mcheli/shaders/post/thermal.json new file mode 100644 index 00000000..10f9a6cc --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/mcheli/shaders/post/thermal.json @@ -0,0 +1,17 @@ +{ + "targets": [ + "swap" + ], + "passes": [ + { + "name": "mcheli_thermal", + "intarget": "minecraft:main", + "outtarget": "swap" + }, + { + "name": "blit", + "intarget": "swap", + "outtarget": "minecraft:main" + } + ] +} diff --git a/minecraft-1.21.1/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.fsh b/minecraft-1.21.1/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.fsh new file mode 100644 index 00000000..4a070551 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.fsh @@ -0,0 +1,32 @@ +#version 150 + +// Thermal view, white hot. A post shader only sees colours, so heat is faked: bright and warm +// colours (fire, lava, skin, lamps) read hot, the sky and water cold, and outlines are drawn +// where the brightness changes sharply, like the original's "pencil" shader. + +uniform sampler2D DiffuseSampler; + +in vec2 texCoord; +in vec2 oneTexel; + +out vec4 fragColor; + +const vec3 Gray = vec3(0.3, 0.59, 0.11); + +float heat(vec3 c) { + float luma = dot(c, Gray); + float warmth = clamp(c.r - c.b, 0.0, 1.0); + return clamp(luma * 0.7 + warmth * 0.6, 0.0, 1.0); +} + +void main() { + float c = heat(texture(DiffuseSampler, texCoord).rgb); + float l = heat(texture(DiffuseSampler, texCoord - vec2(oneTexel.x, 0.0)).rgb); + float r = heat(texture(DiffuseSampler, texCoord + vec2(oneTexel.x, 0.0)).rgb); + float u = heat(texture(DiffuseSampler, texCoord - vec2(0.0, oneTexel.y)).rgb); + float d = heat(texture(DiffuseSampler, texCoord + vec2(0.0, oneTexel.y)).rgb); + float edge = clamp(abs(r - l) + abs(d - u), 0.0, 1.0); + + float value = smoothstep(0.08, 0.85, c) + edge * 0.6; + fragColor = vec4(vec3(clamp(value, 0.0, 1.0)), 1.0); +} diff --git a/minecraft-1.21.1/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.json b/minecraft-1.21.1/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.json new file mode 100644 index 00000000..bb338e9f --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/assets/minecraft/shaders/program/mcheli_thermal.json @@ -0,0 +1,18 @@ +{ + "blend": { + "func": "add", + "srcrgb": "one", + "dstrgb": "zero" + }, + "vertex": "sobel", + "fragment": "mcheli_thermal", + "attributes": [ "Position" ], + "samplers": [ + { "name": "DiffuseSampler" } + ], + "uniforms": [ + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "InSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "OutSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] } + ] +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json b/minecraft-1.21.1/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json new file mode 100644 index 00000000..610afa08 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/chain.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/chain.json new file mode 100644 index 00000000..92d099ca --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/container.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/container.json new file mode 100644 index 00000000..9942f143 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": {"tag": "c:chests/wooden"}, + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/drafting_table.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/drafting_table.json new file mode 100644 index 00000000..4429cf70 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": {"item": "minecraft:crafting_table"}, + "F": {"tag": "minecraft:wooden_fences"}, + "P": {"tag": "minecraft:planks"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fgm148.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fgm148.json new file mode 100644 index 00000000..c9c7c87e --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:glass_blocks/colorless"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fgm148_bullet.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fgm148_bullet.json new file mode 100644 index 00000000..bd8a2af9 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:gunpowders"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fim92.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fim92.json new file mode 100644 index 00000000..064eac79 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:glass_blocks/colorless"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fim92_bullet.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fim92_bullet.json new file mode 100644 index 00000000..29bc0018 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:gunpowders"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fuel.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fuel.json new file mode 100644 index 00000000..99e45469 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/fuel.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "C": {"item": "minecraft:coal"}, + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "ICI", + "III" + ], + "result": { + "id": "mcheli:fuel" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/gltd.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/gltd.json new file mode 100644 index 00000000..b129a876 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": {"tag": "c:storage_blocks/iron"}, + "D": {"tag": "c:gems/diamond"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_green.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_green.json new file mode 100644 index 00000000..66bb96dd --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:green_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:custom_model_data": 1, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_red.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_red.json new file mode 100644 index 00000000..dafb3688 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:red_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:custom_model_data": 2, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_violet.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_violet.json new file mode 100644 index 00000000..7944152c --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:purple_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:custom_model_data": 3, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_yellow.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_yellow.json new file mode 100644 index 00000000..ca70c1ad --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"item": "minecraft:yellow_dye"}, + "Y": {"tag": "c:ingots/iron"} + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:custom_model_data": 4, + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/parachute.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/parachute.json new file mode 100644 index 00000000..66118c43 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/parachute.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "S": {"tag": "c:strings"}, + "W": {"tag": "minecraft:wool"} + }, + "pattern": [ + "WWW", + "S S", + " W " + ], + "result": { + "id": "mcheli:parachute" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/rangefinder.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/rangefinder.json new file mode 100644 index 00000000..eb87cd68 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": {"tag": "c:glass_blocks/colorless"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json new file mode 100644 index 00000000..23ac1b09 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + {"item": "mcheli:rangefinder"}, + {"tag": "c:dusts/redstone"} + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/uav_station.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/uav_station.json new file mode 100644 index 00000000..47615ed5 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"tag": "c:gems/diamond"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:storage_blocks/redstone"} + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/uav_station2.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/uav_station2.json new file mode 100644 index 00000000..2281cea7 --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": {"tag": "c:gems/diamond"}, + "I": {"tag": "c:ingots/iron"}, + "R": {"tag": "c:dusts/redstone"} + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +} diff --git a/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/wrench.json b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/wrench.json new file mode 100644 index 00000000..3b3ab88c --- /dev/null +++ b/minecraft-1.21.1/src/main/resources/data/mcheli/recipe/wrench.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": {"tag": "c:ingots/iron"} + }, + "pattern": [ + " I ", + " II", + "I " + ], + "result": { + "id": "mcheli:wrench" + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 00000000..ff258847 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 00000000..2cf2f6a8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; +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.block.DispenserBlock; +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() { + } + + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + + /** + * 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. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(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-1.21.11/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ContentPacks.java new file mode 100644 index 00000000..d3bfdee5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,187 @@ +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 com.jnhprojects.mcheli.content.pack.PendingPacks; +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.HashSet; +import java.util.List; +import java.util.Set; +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 static final String SOUNDS = "assets/mcheli/sounds/"; + + private final Path folder; + private final PendingPacks pending; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); + this.legacy = legacy; + } + + /** {@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); + PendingPacks.cleanReplaced(folder); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + sounds = soundsOf(accepted); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + + private static @Nullable String refusal(ContentPack pack, long size, long total) { + if (!ServerConfig.isPackAllowed(pack.id())) { + return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; + } + 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-1.21.11/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 00000000..7846b7bf --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/LegacyContent.java new file mode 100644 index 00000000..495bb502 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -0,0 +1,239 @@ +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.List; +import java.util.Locale; +import java.util.Optional; +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.MODERN; + + private final Path folder; + private final Path packRoot; + private @Nullable Component problem; + + 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); + } + + /** 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.11/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 00000000..1a890651 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; +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.ModMenus; +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); + ModBlocks.init(); + ModBlockEntities.init(); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModMenus.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.11/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 00000000..25a3aea8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,262 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * {@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
+ *   /mcheli give <players> <vehicle> [count]   (operators)
+ *   /mcheli reload                       re-read the definitions and packs (operators)
+ *   /mcheli pending                      packs players have sent, waiting (operators)
+ *   /mcheli approve <pack>               put a waiting pack in use (operators)
+ *   /mcheli reject <pack>                throw a waiting pack away (operators)
+ * 
+ */ +public final class McheliCommand { + private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); + private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); + + private McheliCommand() { + } + + 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(Commands.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(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::reload)) + .then(Commands.literal("pending") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + } + + private static int status(CommandContext ctx) { + 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); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + + 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.11/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ModSounds.java new file mode 100644 index 00000000..ed6eeb7e --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -0,0 +1,64 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.core.Holder; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +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. + * + *

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. + */ +public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + + private static final Map EVENTS = new ConcurrentHashMap<>(); + + private ModSounds() { + } + + public static SoundEvent get(String name) { + return EVENTS.computeIfAbsent(name, n -> SoundEvent.createVariableRangeEvent(Identifier.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) { + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); + } + + /** Plays {@code name} at a position for everyone nearby. Call on the server. */ + public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } + } + + /** Plays {@code name} for one player only, such as a hit confirmation. */ + public static void playTo(ServerPlayer player, String name, float volume, float pitch) { + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ServerConfig.java new file mode 100644 index 00000000..9db31738 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,172 @@ +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.BooleanValue INFINITE_FUEL; + 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; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; + + 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()); + INFINITE_FUEL = builder + .comment("Whether vehicles never run out of fuel. Original: InfinityFuel") + .define("infiniteFuel", false); + 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); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); + builder.pop(); + + SPEC = builder.build(); + } + + 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(); + } + + public static boolean infiniteFuel() { + return SPEC.isLoaded() ? INFINITE_FUEL.get() : INFINITE_FUEL.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 allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 00000000..8545d2b5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.block; + +import com.mojang.serialization.MapCodec; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + private static final MapCodec CODEC = simpleCodec(DraftingTableBlock::new); + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.column(16.0, 0.0, 15.0); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 00000000..56238f3f --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 00000000..ab62e120 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 00000000..70bcc23e --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 00000000..4f140cc5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,332 @@ +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.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ParachuteRenderer; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; +import com.jnhprojects.mcheli.client.screen.VehicleScreen; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; +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.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.network.chat.Component; +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 net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; + +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 static boolean shaderProgramsAssigned; + + private ClientHooks() { + } + + /** Loads a post effect into the game renderer, whose own method is private on 1.21.11. */ + public interface PostEffects { + void load(Identifier effect); + } + + private static PostEffects postEffects = effect -> { + }; + + static PostEffects postEffects() { + return postEffects; + } + + /** During mod construction, on a client only. */ + public static void init(PostEffects effects) { + postEffects = effects; + 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); + renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, + BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); + } + + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + + /** On the main thread during client setup, once our menu types are registered. */ + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::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() { + if (!shaderProgramsAssigned) { + // On the first tick rather than at construction, so Iris has surely loaded by now. + shaderProgramsAssigned = true; + Glow.assignShaderPrograms(); + } + CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); + VehicleCamera.tick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + TargetMarkers.tick(); + WorkshopClient.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.setScreen(new TeamManagerScreen()); + } else { + mc.player.displayClientMessage(Component.translatable("message.mcheli.team.operators_only"), true); + } + } + + /** 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); + GltdController.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; + } + } + + /** The field of view for this frame, narrowed while the player zooms through a sight. */ + public static float fov(float fov) { + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); + } + + /** 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() || GltdController.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(); + } + + /** The partial tick the camera entity is drawn at, for the loaders whose event does not carry it. */ + public static float cameraPartialTicks() { + return Minecraft.getInstance().gameRenderer.getMainCamera().getPartialTickTime(); + } + + public static void beforeRenderLiving(LivingEntityRenderState state, PoseStack pose, float partialTicks) { + RiderPose.beforeRender(state, pose, partialTicks); + } + + public static void drawHud(GuiGraphics graphics, DeltaTracker delta) { + WeaponHud.draw(graphics, delta); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.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(message -> player.displayClientMessage(message, false)); + } + + public static void loggedOut() { + ClientPacks.INSTANCE.loggedOut(); + WorkshopClient.loggedOut(); + Markers.clear(); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java new file mode 100644 index 00000000..7e7d61b1 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,338 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +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 com.jnhprojects.mcheli.network.PackSoundPacket; +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. + */ +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + 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(); + } + } + + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + 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.displayClientMessage(message, false); + } 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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/CrewController.java new file mode 100644 index 00000000..32af6770 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -0,0 +1,482 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +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.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Input; +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; + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } + 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; + VehicleCamera.reset(); + } + + Input keys = player.input.keyPresses; + boolean jumpPressed = keys.jump() && !jumpWasDown; + jumpWasDown = keys.jump(); + boolean pilot = seat == 0 && vehicle.getControllingPassenger() == player; + + if (pilot) { + AircraftEntity.ControlInput input = vehicle.input(); + input.throttleUp = keys.forward(); + input.throttleDown = keys.backward(); + input.left = keys.left(); + input.right = keys.right(); + input.freeLook = isFreeLook(vehicle); + input.brake = vehicle instanceof TankEntity && keys.jump(); + 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.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } + 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 (looksThroughSight(vehicle, seat)) { + VehicleCamera.onZoomKey(player, vehicle); + } else if (pilot) { + send(vehicle, CrewActionPacket.Action.FOLD); + } + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } + } + while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { + if (!vehicle.isDestroyed()) { + send(vehicle, CrewActionPacket.Action.VEHICLE_SCREEN); + } + } + 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); + } + } + } + + /** + * Whether this seat sees through a sight that can zoom: a camera position or gunner sight, the + * pilot in gunner mode, or the pilot of a UAV. The zoom key folds the wings everywhere else. + */ + private static boolean looksThroughSight(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount() || def.cameraZoom <= 1) { + return false; + } + return eyePosition(vehicle, def, seat) != null || vehicle.isGunnerMode() || def.uav; + } + + 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; + } + + 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.11/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 00000000..a8db0832 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +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; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.screen == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.drawString(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.drawString(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java new file mode 100644 index 00000000..111759ac --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +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 KeyMapping.Category CATEGORY = KeyMapping.Category.register(Identifier.fromNamespaceAndPath(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); + /** Zooms through a sight, as the original Z key did; otherwise 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); + /** Fuel and parachute slots, reloading and the cargo chest. The original used R, which switches seats here. */ + public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); + /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ + public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, 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, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); + + private KeyBindings() { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 00000000..f502e689 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,238 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +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.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.screen == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(RenderPipelines.GUI_TEXTURED, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), + (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.drawString(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.drawString(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.drawString(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.drawString(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.drawString(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 00000000..d1eb31d9 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,155 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +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.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final Identifier SCOPE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(RenderPipelines.GUI_TEXTURED, SCOPE, (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushMatrix(); + g.pose().translate(w / 2.0F, h / 2.0F); + g.pose().scale(k, k); + drawReadouts(g, mc.font, player, stack); + g.pose().popMatrix(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphics g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.drawString(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.drawString(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.drawString(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.drawString(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.drawString(font, ">", 30, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 00000000..7f7afd84 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,165 @@ +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.Identifier; +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(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + 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); + } + + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(Identifier id) throws IOException { + Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); + 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 Identifier texture(Identifier 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); + } + } + + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(Identifier id) { + TEXTURES.put(id, upload(id)); + } + + private static boolean upload(Identifier id) { + try (InputStream in = open(id)) { + if (in == null) { + return false; + } + NativeImage image = NativeImage.read(in); + Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, 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) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } + for (Path root : roots) { + Path file = inRoot(root, path); + if (file != null) { + return file; + } + } + return null; + } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 00000000..2578a666 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,87 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +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.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3fc; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta.getGameTimeDeltaPartialTick(false); + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "â–¼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "â—†", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphics g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.getMainCamera(); + Vector3fc forward = camera.forwardVector(); + Vec3 offset = at.subtract(camera.position()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + Vec3 ndc = mc.gameRenderer.projectPointToScreen(at); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.drawCenteredString(font, symbol, (int) x, (int) y - 4, color); + g.drawCenteredString(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.drawCenteredString(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.drawCenteredString(font, "â—†", ex, ey - 4, color); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 00000000..de7ef3c4 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,127 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +import java.util.function.Consumer; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision and thermal view for those with {@code EnableNightVision}. Both + * reset whenever the player changes seat or leaves the vehicle. + */ +public final class VehicleCamera { + /** The original's camera modes; the HUD reads the ordinal as {@code cam_mode}. */ + public enum Mode { + NORMAL, + NIGHT_VISION, + /** Night vision seen through a white hot filter, the original's "pencil" shader mode. */ + THERMAL + } + + /** The post effect of the thermal view, {@code assets/mcheli/post_effect/thermal.json}. */ + private static final Identifier THERMAL_EFFECT = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "thermal"); + + private static float zoom = 1.0F; + private static Mode mode = Mode.NORMAL; + + private VehicleCamera() { + } + + public static float zoom() { + return zoom; + } + + public static Mode mode() { + return mode; + } + + /** Back to the plain view, on a seat change or after leaving the vehicle. */ + static void reset() { + zoom = 1.0F; + mode = Mode.NORMAL; + } + + /** + * Keeps the thermal filter in the player's post effects while the thermal view is on. The list + * is the one a server's post effect packet fills, so ours is put back if that replaces it. + */ + static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + boolean active = THERMAL_EFFECT.equals(mc.gameRenderer.currentPostEffect()); + if (mode == Mode.THERMAL && !active) { + ClientHooks.postEffects().load(THERMAL_EFFECT); + } else if (mode != Mode.THERMAL && active) { + mc.gameRenderer.clearPostEffect(); + } + } + + /** + * {@code zoomCamera}: each press doubles the zoom up to the vehicle's {@code CameraZoom}, and + * the press after that goes back to one. The caller checks that the seat looks through a sight. + */ + static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { + refuse(player); + return; + } + zoom = zoom >= max - 0.01F ? 1.0F : Math.min(zoom * 2.0F, max); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + + /** + * {@code switchCameraMode}: steps from the plain view to night vision, to thermal view and + * back. The server applies night vision as an effect in both of the last two; the thermal + * filter is drawn here. + */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { + Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; + if (mode == Mode.NORMAL || next == Mode.NORMAL) { + nightVision.accept(next != Mode.NORMAL); + } + mode = next; + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + + /** The view's field of view, narrowed by the zoom. */ + public static float fov(float fov) { + return zoom > 1.0F ? fov / zoom : fov; + } + + /** {@code playSoundNG}: the key does nothing in this seat. */ + private static void refuse(LocalPlayer player) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java new file mode 100644 index 00000000..9507412f --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java new file mode 100644 index 00000000..c0ab2443 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -0,0 +1,187 @@ +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(); + } + } + + 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().position() : 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.11/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java new file mode 100644 index 00000000..347ac6f1 --- /dev/null +++ b/minecraft-1.21.11/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.VEHICLE_SCREEN.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.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java new file mode 100644 index 00000000..b16caadc --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java @@ -0,0 +1,165 @@ +package com.jnhprojects.mcheli.client.hud; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.Identifier; +import org.joml.Matrix3x2fStack; + +/** + * The drawing primitives of {@code MCH_HudItem}, on the 26.x 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; + } + + Matrix3x2fStack 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) { + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) x, (float) y); + pose.rotate((float) Math.toRadians(degrees)); + pose.translate((float) -x, (float) -y); + } + + void pop() { + g.pose().popMatrix(); + } + + 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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) x0, (float) y0); + pose.rotate((float) Math.atan2(dy, dx)); + pose.translate(0.0F, -LINE_WIDTH / 2.0F); + pose.scale((float) length, LINE_WIDTH); + g.fill(0, 0, 1, 1, color); + pose.popMatrix(); + } + + /** {@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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) left, (float) top); + pose.scale((float) width, (float) height); + g.fill(0, 0, 1, 1, color); + pose.popMatrix(); + } + + 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(Identifier 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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) (left + width / 2.0), (float) (top + height / 2.0)); + pose.rotate((float) Math.toRadians(rotation)); + pose.translate((float) (-width / 2.0), (float) (-height / 2.0)); + pose.scale((float) width, (float) height); + g.blit(texture, 0, 0, 1, 1, + (float) (u / textureWidth), (float) ((u + uWidth) / textureWidth), + (float) (v / textureHeight), (float) ((v + vHeight) / textureHeight)); + pose.popMatrix(); + } + + 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.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java new file mode 100644 index 00000000..4abb7368 --- /dev/null +++ b/minecraft-1.21.11/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.Identifier; +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 -> { + Identifier texture = Identifier.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.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java new file mode 100644 index 00000000..6e611a23 --- /dev/null +++ b/minecraft-1.21.11/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.Identifier; +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, Identifier 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.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java new file mode 100644 index 00000000..59371f55 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -0,0 +1,373 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.client.VehicleCamera; +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 read as their idle state for now: 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; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; + 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; + private int fuelWarnCount; + + /** 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.getMinY()), mob.getBlockZ()); + int blocks = 0; + for (; pos.getY() < level.getMaxY(); 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.getMinY(); 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, vehicle.fuelRatio()); + v.set(HudVariable.LOW_FUEL, lowFuel(vehicle)); + 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, VehicleCamera.mode().ordinal()); + v.set(HudVariable.CAM_ZOOM, VehicleCamera.zoom()); + 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); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); + } + + /** + * {@code isLowFuel}: below 10% the warning blinks, on for eight seconds out of every fourteen. + * Ticks with each update, as the original counted per frame. + */ + private boolean lowFuel(AircraftEntity vehicle) { + if (fuelWarnCount <= 0) { + fuelWarnCount = 280; + } + fuelWarnCount--; + return fuelWarnCount < 160 && vehicle.maxFuel() > 0 && vehicle.fuelRatio() < 0.1F; + } + + /** 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); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; + } else { + vars.set(HudVariable.IS_HEAT_WPN, 0.0); + vars.set(HudVariable.RELOADING, 0.0); + vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; + } + 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, mortarDistance); + } + + 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 -> mortarDistance; + case MC_VER -> "1.21.11"; + 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 -> VehicleCamera.zoom(); + case UAV_DIST -> Math.max(vehicle.stationDistance(), 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.11/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 00000000..6d741611 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java @@ -0,0 +1,103 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +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.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.QuadParticleRenderState; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.util.ARGB; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +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 SingleQuadParticle { + 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, 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; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.0F; + } + + @Override + public void tick() { + super.tick(); + if (flicker) { + waver = 0.7F + random.nextFloat() * 0.3F; + } + } + + @Override + public void extract(QuadParticleRenderState state, 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.extract(state, 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 + protected Layer getLayer() { + 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, RandomSource random) { + 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.first(), strength); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 00000000..2f8240c1 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.QuadParticleRenderState; +import net.minecraft.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends SingleQuadParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z, sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void extract(QuadParticleRenderState state, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.extract(state, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + protected Layer getLayer() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd, RandomSource random) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, random); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java new file mode 100644 index 00000000..fc8cd4c5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java @@ -0,0 +1,59 @@ +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** The close air support A-10, {@code MCH_RenderA10}. */ +public class A10Renderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/bullets/a10.png"); + + public A10Renderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 10.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + boolean visible; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(A10Entity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = entity.isVisible(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.get("", "a-10") : null; + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(-state.pitch)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java new file mode 100644 index 00000000..d2dd770d --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -0,0 +1,77 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import org.jspecify.annotations.Nullable; + +/** + * Snapshot of a vehicle for one frame, filled in by {@link AircraftRenderer#extractRenderState}. + * Every angle is already interpolated for the frame's partial tick. + */ +public class AircraftRenderState extends EntityRenderState { + /** 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; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; + public float throttle; + + // Part states, 0 to 90 degrees + public float canopy; + public float landingGear; + public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; + 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; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; + + /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ + public WeaponPartState[] weaponParts = new WeaponPartState[0]; + /** 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.11/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java new file mode 100644 index 00000000..ec225672 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -0,0 +1,342 @@ +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.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; +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.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.debug.DebugScreenEntries; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.gizmos.GizmoStyle; +import net.minecraft.gizmos.Gizmos; +import net.minecraft.resources.Identifier; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * 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 { + private static final int HULL_COLOR = 0xFF00E0FF; + private static final int GENERATED_COLOR = 0xFFFFC000; + /** Ropes take the colour of brown wool. */ + private static final Identifier ROPE = Identifier.withDefaultNamespace("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; + + public AircraftRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 2.0F; + } + + /** + * With F3+B on, outlines the hull boxes next to vanilla's main box: what projectiles hit and + * what other vehicles collide with. The definition's own boxes are cyan, the ones generated + * from the model amber. + */ + private static void showHull(AircraftEntity entity, AircraftDefinition def, float partialTicks) { + List hull = entity.hullBoxes(); + if (hull.size() < 2) { + return; + } + Vec3 offset = entity.getPosition(partialTicks).subtract(entity.position()); + int listed = def.boundingBoxes.size(); + try (Gizmos.TemporaryCollection ignored = Minecraft.getInstance().levelRenderer.collectPerFrameGizmos()) { + // The first is the main box, which vanilla draws itself; then the listed boxes, then the generated ones. + for (int i = 1; i < hull.size(); i++) { + Gizmos.cuboid(hull.get(i).move(offset), GizmoStyle.stroke(i <= listed ? HULL_COLOR : GENERATED_COLOR)); + } + } + } + + @Override + public AircraftRenderState createRenderState() { + return new AircraftRenderState(); + } + + @Override + public void extractRenderState(T entity, AircraftRenderState state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + 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; + } + if (Minecraft.getInstance().debugEntries.isCurrentlyEnabled(DebugScreenEntries.ENTITY_HITBOXES)) { + showHull(entity, def, partialTicks); + } + + 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 HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getXRot(partialTicks) : state.pitch; + } + 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 + protected AABB getBoundingBoxForCulling(T entity) { + // The hit box is far smaller than most models, so cull on a generous box instead. + 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 submit(AircraftRenderState state, PoseStack poseStack, SubmitNodeCollector collector, CameraRenderState camera) { + 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, collector, texture(def.folder(), state.texture), state.lightCoords, + OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, collector); + } + if (state.searchLight) { + searchLights(def, state, poseStack, collector); + } + poseStack.popPose(); + } + super.submit(state, poseStack, collector, camera); + } + + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.mulPose(Axis.YN.rotationDegrees(state.searchLightYaw - state.yaw + light.yaw())); + pose.mulPose(Axis.XP.rotationDegrees(state.searchLightPitch + 90.0F - state.pitch + light.pitch())); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.mulPose(Axis.YN.rotationDegrees(light.yaw() + steer)); + pose.mulPose(Axis.XP.rotationDegrees(90.0F + light.pitch())); + } + Glow.cone(pose, collector, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + int light = state.lightCoords; + float length = state.ropeLength; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(ROPE), (p, buffer) -> { + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + }); + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.addVertex(p, x, y, z).setColor(0xFFFFFFFF).setUv(u, v).setOverlay(OverlayTexture.NO_OVERLAY).setLight(light).setNormal(p, 0.0F, 1.0F, 0.0F); + } + + private static Identifier texture(String folder, String name) { + return RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 00000000..62c893c5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + /** Towed end, relative to the chain entity. */ + Vec3 from = Vec3.ZERO; + /** From the towed end to the end that pulls. */ + Vec3 span = Vec3.ZERO; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + protected AABB getBoundingBoxForCulling(ChainEntity chain) { + AABB box = super.getBoundingBoxForCulling(chain); + Entity towed = chain.towed(); + return towed != null ? box.minmax(towed.getBoundingBox()) : box; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ChainEntity chain, State state, float partialTicks) { + super.extractRenderState(chain, state, partialTicks); + Entity tow = chain.tow(); + Entity towed = chain.towed(); + if (tow == null || towed == null) { + state.span = Vec3.ZERO; + return; + } + Vec3 here = chain.getPosition(partialTicks); + Vec3 start = towed.getPosition(partialTicks); + state.from = start.subtract(here); + state.span = tow.getPosition(partialTicks).subtract(start); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + double length = state.span.length(); + if (mesh != null && length > LINK) { + Vec3 step = state.span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + int light = state.lightCoords; + pose.pushPose(); + pose.translate(state.from); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-yaw)); + pose.mulPose(Axis.ZP.rotationDegrees(pitch)); + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, GREY)); + pose.popPose(); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 00000000..edfd0742 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + public static class State extends BlockEntityRenderState { + int rotation; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(DraftingTableBlockEntity table, State state, float partialTicks, Vec3 cameraPosition, + ModelFeatureRenderer.@Nullable CrumblingOverlay breakProgress) { + BlockEntityRenderer.super.extractRenderState(table, state, partialTicks, cameraPosition, breakProgress); + state.rotation = table.getBlockState().getValue(DraftingTableBlock.ROTATION); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.mulPose(Axis.YP.rotationDegrees(-state.rotation * 45.0F + 180.0F)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + pose.popPose(); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 00000000..330a2800 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final Identifier texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(T entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.pitch)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, tint)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 00000000..9f8f2f9d --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java @@ -0,0 +1,50 @@ +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.world.phys.Vec3; + +/** + * 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; + } + + public static class State extends EntityRenderState { + float glow; + float flicker = 1.0F; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(FlareEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.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. + state.flicker = 0.8F + 0.2F * (float) Math.sin((entity.tickCount + partialTicks) * 1.7F + entity.getId()); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * state.flicker, GlowParticleOptions.FIRE, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * 0.35F, GlowParticleOptions.FLASH, state.glow); + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java new file mode 100644 index 00000000..d2a46857 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.resources.Identifier; +import org.slf4j.Logger; +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 final Map, Map, List>> buckets = new IdentityHashMap<>(); + + private GlassSplit() { + } + + /** Called from the render thread only. */ + Split split(List groups, Identifier texture) { + if (groups.isEmpty()) { + return Split.EMPTY; + } + return splits.computeIfAbsent(groups, g -> new HashMap<>()).computeIfAbsent(texture, t -> compute(groups, t)); + } + + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + + void clear() { + textures.clear(); + splits.clear(); + buckets.clear(); + } + + private Split compute(List groups, Identifier texture) { + 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(Identifier 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.getPixel(x, y) >>> 24); + } + } + return new Alpha(w, h, values); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 00000000..73947d7f --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,206 @@ +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.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ARGB; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.util.Mth; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; + +/** + * 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. + * With order independent transparency on, the vanilla translucent pipelines stand in. + */ +public final class Glow { + /** A white disc fading to nothing at the rim; also the {@code mcheli:glow} particle sprite. */ + public static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/particle/glow.png"); + + private static final RenderPipeline PARTICLE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow_particle")) + .withBlend(BlendFunction.LIGHTNING) + .withDepthWrite(false) + .build(); + + /** + * Vanilla's translucent particles with depth writes off. A puff's clear edge would otherwise + * hide the puffs drawn after it, and a smoke cloud is mostly overlapping puffs. + */ + private static final RenderPipeline SMOKE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/smoke_particle")) + .withBlend(BlendFunction.TRANSLUCENT) + .withDepthWrite(false) + .build(); + + private static final RenderPipeline ENTITY_PIPELINE = RenderPipeline.builder(RenderPipelines.MATRICES_FOG_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow")) + .withVertexShader("core/entity") + .withFragmentShader("core/entity") + .withShaderDefine("EMISSIVE") + .withShaderDefine("NO_OVERLAY") + .withShaderDefine("NO_CARDINAL_LIGHTING") + .withSampler("Sampler0") + .withVertexFormat(DefaultVertexFormat.NEW_ENTITY, VertexFormat.Mode.QUADS) + .withBlend(BlendFunction.LIGHTNING) + .withDepthWrite(false) + .build(); + + /** The particle layer for {@code mcheli:glow}. */ + @SuppressWarnings("deprecation") // Vanilla's own particle layers still name the atlas this way. + public static final SingleQuadParticle.Layer PARTICLE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, PARTICLE_PIPELINE); + + /** The particle layer for {@code mcheli:smoke}. */ + @SuppressWarnings("deprecation") + public static final SingleQuadParticle.Layer SMOKE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, SMOKE_PIPELINE); + + private static final RenderType HALO = RenderType.create(MCHeli.MOD_ID + "_glow", RenderSetup.builder(ENTITY_PIPELINE) + .withTexture("Sampler0", TEXTURE) + .sortOnUpload() + .createRenderSetup()); + + private static final int FULL_BRIGHT = LightTexture.FULL_BRIGHT; + + private Glow() { + } + + /** + * Hands our pipelines to Iris, so a shader pack draws them with a matching program: the halos + * and tracers as emissive entities, the glow and smoke particles as translucent particles. + * Called once the mods are loaded; without Iris it does nothing. + */ + public static void assignShaderPrograms() { + IrisCompat.assign(ENTITY_PIPELINE, "EMISSIVE_ENTITIES"); + IrisCompat.assign(PARTICLE_PIPELINE, "PARTICLES_TRANSLUCENT"); + IrisCompat.assign(SMOKE_PIPELINE, "PARTICLES_TRANSLUCENT"); + } + + /** + * 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 color RGB; {@code strength} becomes the alpha, which scales the light added + */ + public static void halo(PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera, Vec3 offset, float radius, int color, + float strength) { + if (strength <= 0.0F || radius <= 0.0F) { + return; + } + int argb = ARGB.color(alpha(strength), color); + pose.pushPose(); + pose.translate(offset); + pose.mulPose(camera.orientation); + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // 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, SubmitNodeCollector collector, 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 = ARGB.color(alpha(strength), color); + int end = ARGB.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; + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // 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); + }); + } + + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, SubmitNodeCollector collector, float height, float radius, int start, int end) { + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + }); + } + + 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); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 00000000..fed95cc9 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +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.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float headYaw; + float headPitch; + boolean drawHead = true; + boolean laser; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(GltdEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + state.laser = viewer != null; + state.drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + state.headYaw = viewer != null ? viewer.getYRot(partialTicks) : state.yaw; + state.headPitch = viewer != null ? viewer.getXRot(partialTicks) : 0.0F; + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + int light = state.lightCoords; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + draw(mesh.part("$body"), pose, collector, light); + pose.popPose(); + if (state.drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.headYaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.headPitch)); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, collector, light); + pose.popPose(); + } + } + if (state.laser) { + float yaw = state.headYaw * Mth.DEG_TO_RAD; + float pitch = state.headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = new Vec3(state.x, state.y, state.z).add(head).subtract(camera.pos); + pose.pushPose(); + pose.translate(head); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, collector, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static void draw(List groups, PoseStack pose, SubmitNodeCollector collector, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java new file mode 100644 index 00000000..3803102e --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java @@ -0,0 +1,49 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.pipeline.RenderPipeline; + +import java.lang.reflect.Method; + +/** + * Tells Iris which shader program draws our own render pipelines. Iris only knows the vanilla + * pipelines and draws any other with a program whose vertex layout does not match, which throws + * the vertices far off: the glows turn into long coloured lines running to the horizon. Iris is + * optional, so its API is looked up by reflection instead of compiled against. + */ +final class IrisCompat { + private static final String API = "net.irisshaders.iris.api.v0.IrisApi"; + private static final String PROGRAM = "net.irisshaders.iris.api.v0.IrisProgram"; + + private IrisCompat() { + } + + /** + * Assigns {@code pipeline} to the Iris program named {@code program}, such as + * {@code EMISSIVE_ENTITIES}. Does nothing without Iris. + * + * @return whether Iris took it + */ + static boolean assign(RenderPipeline pipeline, String program) { + Class api; + try { + api = Class.forName(API); + } catch (ClassNotFoundException absent) { + MCHeli.LOGGER.debug("Iris is not installed, {} keeps its own shader", pipeline.getLocation()); + return false; + } + try { + Class programs = Class.forName(PROGRAM); + Object instance = api.getMethod("getInstance").invoke(null); + Object target = programs.getField(program).get(null); + Method assign = api.getMethod("assignPipeline", RenderPipeline.class, programs); + assign.invoke(instance, pipeline, target); + MCHeli.LOGGER.debug("Iris draws {} with its {} program", pipeline.getLocation(), program); + return true; + } catch (ReflectiveOperationException | LinkageError e) { + // An Iris without this API: glows may look wrong under a shader pack, nothing worse. + MCHeli.LOGGER.warn("Could not hand {} to Iris, glows may look wrong with shaders: {}", pipeline.getLocation(), e.toString()); + return false; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java new file mode 100644 index 00000000..57dad7e5 --- /dev/null +++ b/minecraft-1.21.11/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.Identifier; +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) { + Identifier id = Identifier.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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java new file mode 100644 index 00000000..47cc66e3 --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 00000000..061c6f0f --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * {@code MCH_RenderParachute}: the item's round canopy, or the ejection seat with its canopy, + * from the models the import brought in. + */ +public class ParachuteRenderer extends EntityRenderer { + private static final Identifier ROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute1.png"); + private static final Identifier SEAT = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute2.png"); + + public ParachuteRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + ParachuteEntity.Kind kind = ParachuteEntity.Kind.ITEM; + boolean open; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ParachuteEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.open = entity.isOpen(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + List groups = groups(state); + if (!groups.isEmpty()) { + Identifier texture = state.kind == ParachuteEntity.Kind.ITEM ? ROUND : SEAT; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static List groups(State state) { + if (state.kind == ParachuteEntity.Kind.ITEM) { + Mesh mesh = MeshCache.INSTANCE.get("", "parachute1"); + return mesh != null ? mesh.groups() : List.of(); + } + Mesh mesh = MeshCache.INSTANCE.get("", "parachute2"); + boolean canopy = state.kind == ParachuteEntity.Kind.DROP || state.open; + return mesh != null ? mesh.part(canopy ? "$parachute" : "$seat") : List.of(); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java new file mode 100644 index 00000000..6eb9b8bb --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -0,0 +1,554 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +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.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.resources.Identifier; +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 SubmitNodeCollector collector; + private final Identifier 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, SubmitNodeCollector collector, + Identifier texture, int light, int overlay, int glassColor) { + this.def = def; + this.mesh = mesh; + this.state = state; + this.pose = pose; + this.collector = collector; + 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 = RenderTypes.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 = RenderTypes.entityTranslucentEmissive(texture); + this.light = light; + this.overlay = overlay; + this.glassColor = glassColor; + } + + void submitAll() { + switch (def) { + case PlaneDefinition plane -> { + nozzles(plane); + wings(plane); + propellers(plane); + hull(); + } + case HelicopterDefinition heli -> { + hull(); + rotors(heli); + } + case VehicleDefinition vehicle -> { + hull(); + for (VehiclePart part : vehicle.parts) { + vehiclePart(vehicle, part); + } + } + default -> hull(); + } + weapons(); + rotatingParts(); + movingParts(def.hatches, state.hatch); + trackRollers(); + crawlerTracks(); + steeringWheels(); + wheels(); + throttles(); + cameras(); + landingGear(); + weaponBays(); + movingParts(def.canopies, state.canopy); + } + + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + + // Kind specific parts + + private void rotors(HelicopterDefinition heli) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); + 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); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : 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; + } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + Identifier own = RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderTypes.entityCutout(own), RenderTypes.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, Identifier texture, RenderType opaqueType, RenderType glassType) { + GlassSplit.Split split = GlassSplit.INSTANCE.split(groups, texture); + if (!split.opaque().isEmpty()) { + collector.submitCustomGeometry(pose, opaqueType, (p, buffer) -> MeshRenderer.emit(split.opaque(), p, buffer, light, overlay)); + } + if (!split.glass().isEmpty()) { + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + collector.submitCustomGeometry(pose, glassType, state.inGui + ? (p, buffer) -> MeshRenderer.emit(split.glass(), p, buffer, light, overlay, glassColor) + : (p, buffer) -> MeshRenderer.emitFrontFaces(split.glass(), p, buffer, light, overlay, glassColor)); + } + } + + 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.11/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java new file mode 100644 index 00000000..38c5df0e --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -0,0 +1,181 @@ +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.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Util; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import java.util.function.Function; + +/** + * 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 { + /** Vanilla's own draws into the item target; these rounds belong in the main one. */ + private static final Function TRANSLUCENT_CULL = Util.memoize(texture -> RenderType.create( + MCHeli.MOD_ID + "_translucent_cull", RenderSetup.builder(RenderPipelines.ITEM_ENTITY_TRANSLUCENT_CULL) + .withTexture("Sampler0", texture) + .useLightmap() + .useOverlay() + .affectsCrumbling() + .sortOnUpload() + .setOutline(RenderSetup.OutlineProperty.AFFECTS_OUTLINE) + .createRenderSetup())); + 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 + } + + public static class State extends EntityRenderState { + 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; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ProjectileEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.model = entity.model(); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = !entity.isSpent() && (!(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 || entity.isSpent() ? 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 submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.find("bullets", state.model) : null; + if (mesh != null) { + Identifier texture = Identifier.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)); + int color = state.color; + collector.submitCustomGeometry(pose, TRANSLUCENT_CULL.apply(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, FULL_BRIGHT, OverlayTexture.NO_OVERLAY, color)); + pose.popPose(); + } + if (state.visible) { + submitGlow(state, pose, collector, camera); + } + super.submit(state, pose, collector, camera); + } + + private static void submitGlow(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + 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 toCamera = new Vec3(state.x - camera.pos.x, state.y - camera.pos.y, state.z - camera.pos.z); + Glow.streak(pose, collector, toCamera, back.scale(Math.min(speed, MAX_TRACER)), 0.08F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, 0.35F, state.glowColor, state.glow); + } + case MOTOR -> { + Vec3 nozzle = back.scale(MOTOR_OFFSET); + Glow.halo(pose, collector, camera, nozzle, 1.1F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, nozzle, 0.4F, GlowParticleOptions.FLASH, state.glow); + } + default -> { + } + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java new file mode 100644 index 00000000..9ddb1690 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -0,0 +1,49 @@ +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.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 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(LivingEntityRenderState renderState, PoseStack pose, float partialTicks) { + // Render states carry no entity reference; players are the ones that keep their id. + if (!(renderState instanceof AvatarRenderState state)) { + return; + } + ClientLevel level = Minecraft.getInstance().level; + Entity rider = level != null ? level.getEntity(state.id) : null; + if (rider == null || !(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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 00000000..fe472a4b --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,55 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + String name = ""; + float yaw; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ThrowableEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.name = entity.definitionName(); + state.yaw = entity.getYRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", state.name); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/throwable/" + state.name + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 00000000..41fe7a21 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + public static class State extends EntityRenderState { + float yaw; + int kind; + boolean lit; + float cover; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(UavStationEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.cover = entity.cover(partialTicks); + state.lit = state.kind == UavStationEntity.PORTABLE ? state.cover > 0.95F : entity.isLinked(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + int index = state.kind == UavStationEntity.PORTABLE ? 1 : 0; + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[index] + (state.lit ? "_on" : "") + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + if (index == 0) { + draw(pose, collector, texture, mesh.groups(), light); + } else { + draw(pose, collector, texture, mesh.part("$body"), light); + folding(pose, collector, texture, mesh.part("$cover"), state.cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, collector, texture, mesh.part("$laptop_cover"), state.cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, collector, texture, mesh.part("$display"), state.cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List part, + float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.mulPose(Axis.XN.rotationDegrees(angle)); + pose.translate(0.0F, -y, -z); + draw(pose, collector, texture, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List groups, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 00000000..1966cc4a --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,336 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.recipebook.RecipeCollection; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedItemContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.RecipeDisplay; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the player's recipe book, which holds every MCHeli recipe from login. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 400; + imageHeight = 240; + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); + selectList(list); + } + + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.setScreen(new WorkshopHomeScreen()); + } + + /** Every MCHeli recipe the recipe book holds, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + for (RecipeCollection collection : minecraft.player.getRecipeBook().getCollections()) { + for (RecipeDisplayEntry entry : collection.getRecipes()) { + ItemStack result = entry.display().result().resolveForFirstStack(context); + if (!result.isEmpty() && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(entry); + } + } + } + for (List entries : lists) { + entries.sort(Comparator.comparing(e -> e.display().result().resolveForFirstStack(context).getHoverName().getString())); + } + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeDisplayEntry current() { + List entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeDisplayEntry entry = current(); + return entry != null ? entry.display().result().resolveForFirstStack(SlotDisplayContext.fromLevel(minecraft.level)) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static Identifier pageTexture(String name, int page) { + return Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeDisplayEntry entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedItemContents contents = new StackedItemContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return entry.canCraft(contents); + } + + private void create() { + RecipeDisplayEntry entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(RenderPipelines.GUI_TEXTURED, pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, 0.0F, 0.0F, + PAGE_WIDTH / 2, PAGE_HEIGHT / 2, PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeDisplayEntry entry = current(); + if (entry == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.drawString(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.drawString(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.display(), mouseX, mouseY); + graphics.renderItem(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphics graphics, RecipeDisplay display, int mouseX, int mouseY) { + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + List slots; + int width; + switch (display) { + case ShapedCraftingRecipeDisplay shaped -> { + slots = shaped.ingredients(); + width = shaped.width(); + } + case ShapelessCraftingRecipeDisplay shapeless -> { + slots = shapeless.ingredients(); + width = 3; + } + default -> { + return; + } + } + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + List options = slots.get(i).resolveForStacks(context); + if (options.isEmpty()) { + continue; + } + ItemStack stack = options.get(tick % options.size()); + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.renderItem(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.setTooltipForNextFrame(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphics graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.drawString(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 00000000..3ec2606f --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,136 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamManager.COLORS.indexOf(ChatFormatting.RED); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamManager.COLORS.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + ChatFormatting teamColor = TeamManager.COLORS.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getName()).withStyle(teamColor))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphics graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.drawString(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.drawString(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 00000000..232e98e8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 166; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.drawString(font, label, 8, 6, color, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.drawString(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java new file mode 100644 index 00000000..601b99d1 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java @@ -0,0 +1,181 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGui}: fuel and parachute slots on the left, the weapon + * panel on the right with its reload cost and button, and a button into the cargo chest for + * vehicles that have one. The panel is drawn from the chest texture so it looks like vanilla. + */ +public class VehicleScreen extends AbstractContainerScreen { + private static final Identifier CHEST = Identifier.withDefaultNamespace("textures/gui/container/generic_54.png"); + private static final Identifier SLOT = Identifier.withDefaultNamespace("container/slot"); + /** Height of the part above the player inventory. */ + private static final int TOP = 88; + private static final int PANEL_X = 72; + private static final int PANEL_WIDTH = 96; + private static final int LABEL = 0xFF404040; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF5555; + private static final int GREEN = 0xFF55FF55; + + private int weapon; + private boolean cargoButton; + private @Nullable Button reload; + private @Nullable Button previous; + private @Nullable Button next; + + public VehicleScreen(VehicleMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = TOP + 96; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + previous = addRenderableWidget(Button.builder(Component.literal("<"), b -> step(-1)) + .bounds(leftPos + PANEL_X, topPos + 68, 16, 16).build()); + reload = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.reload"), b -> press(weapon)) + .bounds(leftPos + PANEL_X + 18, topPos + 68, PANEL_WIDTH - 36, 16).build()); + next = addRenderableWidget(Button.builder(Component.literal(">"), b -> step(1)) + .bounds(leftPos + PANEL_X + PANEL_WIDTH - 16, topPos + 68, 16, 16).build()); + AircraftEntity vehicle = menu.vehicle(); + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + cargoButton = def != null && def.cargoRows() > 0; + if (cargoButton) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.cargo"), b -> press(VehicleMenu.BUTTON_CARGO)) + .bounds(leftPos + imageWidth - 52, topPos + 3, 46, 13).build()); + } + updateButtons(); + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + private int weaponCount() { + AircraftEntity vehicle = menu.vehicle(); + VehicleWeapons weapons = vehicle != null ? vehicle.weapons() : null; + return weapons != null ? Math.min(weapons.sets().size(), VehicleMenu.MAX_WEAPONS) : 0; + } + + private void step(int direction) { + int count = weaponCount(); + if (count > 0) { + weapon = Math.floorMod(weapon + direction, count); + updateButtons(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + private void updateButtons() { + if (reload == null || previous == null || next == null) { + return; + } + int count = weaponCount(); + reload.visible = count > 0; + previous.visible = count > 1; + next.visible = count > 1; + reload.active = menu.canReload(weapon); + AircraftEntity vehicle = menu.vehicle(); + boolean grounded = vehicle != null && vehicle.canSupply(); + reload.setTooltip(grounded ? null : Tooltip.create(Component.translatable("gui.mcheli.vehicle.reload_hint"))); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + int x = leftPos; + int y = topPos; + // The chest's title bar, its plain middle stretched down to the inventory, then the inventory. + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y, 0.0F, 0.0F, imageWidth, 17, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y + 17, 0.0F, 12.0F, imageWidth, TOP - 17, imageWidth, 1, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y + TOP, 0.0F, 126.0F, imageWidth, 96, 256, 256); + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x + VehicleMenu.FUEL_X + i * 18 - 1, y + VehicleMenu.FUEL_Y - 1, 18, 18); + } + for (int i = 0; i < menu.parachuteSlots(); i++) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x + VehicleMenu.PARACHUTE_X + i * 18 - 1, y + VehicleMenu.PARACHUTE_Y - 1, 18, 18); + } + graphics.fill(x + PANEL_X - 2, y + 17, x + PANEL_X + PANEL_WIDTH + 2, y + 66, 0xFF373737); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + AircraftEntity vehicle = menu.vehicle(); + // Long names only give way to the Cargo button, where there is one. + int titleWidth = cargoButton ? imageWidth - 64 : imageWidth - 16; + graphics.drawString(font, font.plainSubstrByWidth(title.getString(), titleWidth), titleLabelX, titleLabelY, LABEL, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, LABEL, false); + if (vehicle == null) { + return; + } + if (vehicle.maxFuel() > 0) { + // A percentage and a gauge: the exact units would run into the weapon panel. + int percent = Math.round(vehicle.fuelRatio() * 100.0F); + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.fuel", percent), VehicleMenu.FUEL_X, VehicleMenu.FUEL_Y - 11, LABEL, false); + int gaugeY = VehicleMenu.FUEL_Y + 18; + int width = VehicleInventory.FUEL_SLOTS * 18 - 2; + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width, gaugeY + 3, 0xFF373737); + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width * percent / 100, gaugeY + 3, percent <= 10 ? RED : GREEN); + } + if (menu.parachuteSlots() > 0) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.parachute"), VehicleMenu.PARACHUTE_X, VehicleMenu.PARACHUTE_Y - 11, LABEL, false); + } + weaponPanel(graphics, vehicle); + } + + /** Name, rounds carried against the maximum, and what one reload costs. */ + private void weaponPanel(GuiGraphics graphics, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null && weaponCount() > 0 ? weapons.set(Math.min(weapon, weaponCount() - 1)) : null; + if (set == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.no_weapons"), PANEL_X, 20, WHITE, false); + return; + } + graphics.drawString(font, font.plainSubstrByWidth(set.displayName(), PANEL_WIDTH), PANEL_X, 20, WHITE, false); + int rounds = menu.rounds(weapon); + int max = set.maxAmmo(); + int color = rounds == 0 && max > 0 ? RED : rounds >= max ? GREEN : WHITE; + graphics.drawString(font, rounds + " / " + max, PANEL_X, 31, color, false); + int itemX = PANEL_X; + for (RoundItem cost : set.definition().roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null) { + continue; + } + graphics.renderItem(new ItemStack(item), itemX, 42); + graphics.drawString(font, String.valueOf(cost.count()), itemX + 17, 47, WHITE, false); + itemX += 32; + if (itemX > PANEL_X + PANEL_WIDTH - 16) { + break; + } + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java new file mode 100644 index 00000000..b32040f5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. + * + *

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. + */ +final class EngineSound extends RuntimeSoundInstance { + private static final float MAX_DOPPLER = 0.2F; + + private final AircraftEntity vehicle; + private final EngineSoundSource source; + private final boolean rotor; + private float doppler; + private double lastDistance = -1.0; + + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.vehicle = vehicle; + this.source = source; + this.rotor = rotor; + this.looping = true; + this.delay = 0; + update(); + } + + @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 = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); + boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); + if (aboard) { + x = vehicle.getX(); + 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-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java new file mode 100644 index 00000000..62e52284 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -0,0 +1,75 @@ +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 and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. + */ +public final class EngineSoundPlayer { + /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ + private static final int RESTART_DELAY = 20; + + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); + + private EngineSoundPlayer() { + } + + 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; + } + AircraftDefinition def = vehicle.definition(); + if (def == null) { + continue; + } + double range = def.soundRange * 1.2; + if (vehicle.distanceToSqr(player) > range * range) { + continue; + } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); + } + } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 00000000..4166ffb8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +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.Identifier; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.util.Util; +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 = super.resolve(manager); + if (events != null) { + return events; + } + file = RuntimeAssets.soundFile(identifier.getPath()); + if (file == null) { + return null; + } + // Streamed, so the file is read as it plays and never cached as a buffer. + sound = new Sound(identifier, ConstantFloat.of(1.0F), ConstantFloat.of(1.0F), 1, Sound.Type.FILE, true, false, 16); + return new WeighedSoundEvents(identifier, null); + } + + /** + * NeoForge calls this through its sound instance extension, which it overrides without saying + * so. Fabric has no such method and calls {@link #openStream} from a mixin. Forge declares it + * too but never calls it on this version, see {@code PackSoundBuffers} in its module. + */ + 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(path, looping); + } + return streamFile(source, looping); + } + + /** Streams an OGG file from disk, read as it plays and never cached as a buffer. */ + public static CompletableFuture streamFile(Path source, boolean 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.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..26401727 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; + +/** + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, so the undo history and the view are + * as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + + public static void tick() { + tickSubmit(); + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.screen == null) { + mc.setScreen(screen); + } + } + } + + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + + public static void loggedOut() { + submitting = null; + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..2ad552c6 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,201 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.home.packs"), b -> minecraft.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().name() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.drawString(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.drawString(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..c83d6d17 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Util.getPlatform().openPath(folder); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.setScreen(new WorkshopHomeScreen()); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + if (rows.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphics graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphics graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.drawString(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..19435963 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1646 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.AssetImport; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Matrix3x2fStack; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

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

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

Movement is simulated wherever {@link #isLocalInstanceAuthoritative()} 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 VehicleEntity { + 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); + private static final EntityDataAccessor DATA_FUEL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + /** The UAV station a drone is flown from, by entity id, or 0. */ + private static final EntityDataAccessor DATA_STATION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_REPELLING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SEARCH_LIGHT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + /** Marks a player flying a drone from a station, so a stale hide can be undone at login. */ + public static final String REMOTE_PILOT_TAG = "mcheli_remote_pilot"; + + // 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; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + 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<>(); + /** The station a drone is flown from, and where its pilot goes back to. Server only. */ + private @Nullable UUID station; + private @Nullable Vec3 stationReturn; + /** Remote pilots leaving this tick, by entity id, with where they come back down. */ + private final Map remoteReturns = new HashMap<>(); + /** Where a workshop test flight started, or {@code null} for an ordinary vehicle. Server only. */ + private @Nullable Vec3 testFlightStart; + private boolean testFlightOver; + /** How far the ropes hang below their hooks, as a negative number; both sides let them out. */ + private float ropeLength; + private int repellingTicks; + private int lastRope; + /** Passengers are leaving one by one under parachutes. Server only. */ + private boolean droppingTroops; + + 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; + /** The fuel load from which an aircraft counts as heavy: the H8K flying boat and up. */ + private static final int HEAVY_FUEL = 3000; + /** How long a wreck stays, burning, before it disappears: 30 seconds. */ + private static final int WRECK_TICKS = 600; + /** 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; + /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ + private int wreckTicks; + /** Ticks since this aircraft was shot down, for the crash spin. Counted on both sides. */ + private int spinTicks; + 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; + + /** Fuel burnt but not yet taken from the tank, which only holds whole units. Server only. */ + private double fuelBurnt; + /** Ticks a fuel truck nearby keeps this vehicle from burning fuel. Server only. */ + private int fuelSuppliedCount; + /** One bit per seat whose occupant has night vision on. Server only, not saved. */ + private int nightVisionSeats; + /** Fuel and parachute slots and the cargo chest. Only the server's copy holds anything. */ + private final VehicleInventory inventory = new VehicleInventory(this); + + // 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; + /** + * How far a hull reaches past its main box, at most, when looking for neighbours: other + * vehicles are found by their main box, and the longest hulls stretch about this far from it. + */ + private static final double HULL_REACH = 24.0; + /** 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) { + super.defineSynchedData(builder); + 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); + builder.define(DATA_FUEL, 0); + builder.define(DATA_STATION, 0); + builder.define(DATA_REPELLING, false); + builder.define(DATA_SEARCH_LIGHT, 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); + entityData.set(DATA_FUEL, definition.maxFuel); + 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); + } + + /** + * {@code repair}: adds hit points up to the maximum. A wreck stays a wreck. Server only. + * + * @return whether anything was repaired + */ + public boolean repair(int amount) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || hp() >= def.maxHp) { + return false; + } + entityData.set(DATA_HP, Math.min(hp() + amount, def.maxHp)); + return true; + } + + /** + * {@code switchNextTextureName}: the next paint scheme the definition lists. Server only. + * + * @return false when the vehicle has only one + */ + public boolean nextTexture() { + AircraftDefinition def = definition(); + if (def == null || def.textures().size() < 2) { + return false; + } + List textures = def.textures(); + int next = (textures.indexOf(texture()) + 1) % textures.size(); + entityData.set(DATA_TEXTURE, textures.get(next)); + return true; + } + + // Fuel + + public int fuel() { + return entityData.get(DATA_FUEL); + } + + public int maxFuel() { + AircraftDefinition def = definition(); + return def != null ? def.maxFuel : 0; + } + + /** {@code getFuelP}: how full the tank is, 0 to 1. Vehicles without a tank read as full. */ + public float fuelRatio() { + int max = maxFuel(); + return max <= 0 ? 1.0F : (float) fuel() / max; + } + + /** {@code canUseFuel}: a vehicle without a tank never runs dry. */ + public boolean hasFuel() { + return maxFuel() <= 0 || fuel() > 1; + } + + /** Sets the tank outright; a drone leaves its station with 5%. Server only. */ + public void setFuelLevel(int fuel) { + entityData.set(DATA_FUEL, Math.clamp(fuel, 0, Math.max(maxFuel(), 0))); + } + + /** + * Pours up to {@code amount} into the tank. Server only. + * + * @return how much went in + */ + public int refuel(int amount) { + int added = Math.clamp(maxFuel() - fuel(), 0, Math.max(amount, 0)); + if (added > 0) { + entityData.set(DATA_FUEL, fuel() + added); + } + return added; + } + + // Night vision + + /** A crew member switched night vision on or off. Server only. */ + public void setNightVision(Player player, boolean on) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (seat < 0 || seat >= 31) { + return; + } + if (on && def != null && def.nightVision && !isDestroyed()) { + nightVisionSeats |= 1 << seat; + } else if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(player); + } + } + + /** Keeps night vision on for everyone in the crew who switched it on, see {@link NightVision}. */ + private void tickNightVision() { + if (nightVisionSeats == 0) { + return; + } + if (isDestroyed()) { + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (crew != null) { + NightVision.end(crew); + } + } + nightVisionSeats = 0; + return; + } + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (hasNightVision(seat) && crew != null) { + NightVision.topUp(crew); + } + } + } + + /** Only the first 31 seats can switch it on; racks and the like come after them anyway. */ + private boolean hasNightVision(int seat) { + return seat >= 0 && seat < 31 && (nightVisionSeats & 1 << seat) != 0; + } + + /** + * {@code updateFuel}: once a second the engine burns its {@code FuelConsumption}, scaled by the + * throttle and topping out at 70% throttle. Nothing burns next to a fuel truck or with the + * server's infinite fuel on. + */ + private void burnFuel(AircraftDefinition def) { + if (def.maxFuel <= 0 || isDestroyed()) { + return; + } + if (fuelSuppliedCount > 0) { + fuelSuppliedCount--; + return; + } + if (ticksAlive % 20 != 0 || fuel() <= 1 || throttle <= 0.0 || ServerConfig.infiniteFuel()) { + return; + } + fuelBurnt += Math.min(throttle * 1.4, 1.0) * def.fuelConsumption; + if (fuelBurnt > 1.0) { + int whole = (int) fuelBurnt; + fuelBurnt -= whole; + entityData.set(DATA_FUEL, Math.max(fuel() - whole, 0)); + } + } + + /** + * {@code supplyFuel}: a fuel truck ({@code FuelSupplyRange}) tops up every vehicle within range, + * 30 units every half second. While it is on the ground it only reaches vehicles that are on + * the ground too. + */ + private void supplyFuel(AircraftDefinition def) { + float range = def.fuelSupplyRange; + if (range <= 0.0F || ticksAlive % 10 != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + if (other == this) { + continue; + } + if (!onGround() || other.onGround()) { + other.refuel(30); + } + other.fuelSuppliedCount = 40; + } + } + + // Inventory and supply + + public VehicleInventory inventory() { + return inventory; + } + + /** + * {@code canSupply}: fuel cans and resupply only work while the vehicle stands on something, + * within three blocks; a vehicle that cannot float must not be in the water either. + */ + public boolean canSupply() { + AircraftDefinition def = definition(); + return def != null && hasBlockBelow(1, 3, false) && (def.floats || !isInWater()); + } + + /** {@code openGui}: shows a crew member the vehicle screen. Server only. */ + public void openScreen(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || player.getVehicle() != this) { + return; + } + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new VehicleMenu(id, playerInventory, this), displayName(def))); + } + + /** + * {@code openInventory}: shows the cargo chest of a vehicle with an {@code InventorySize}. + * Server only. + * + * @return false when the vehicle has none + */ + public boolean openCargo(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || def.cargoRows() <= 0 || isDestroyed() || !inventory.stillValid(player)) { + return false; + } + int rows = Math.min(def.cargoRows(), 6); + MenuType type = switch (rows) { + case 1 -> MenuType.GENERIC_9x1; + case 2 -> MenuType.GENERIC_9x2; + case 3 -> MenuType.GENERIC_9x3; + case 4 -> MenuType.GENERIC_9x4; + case 5 -> MenuType.GENERIC_9x5; + default -> MenuType.GENERIC_9x6; + }; + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new ChestMenu(type, id, playerInventory, inventory.cargo(), rows), + displayName(def))); + return true; + } + + /** The vehicle's name as its item shows it. */ + private static Component displayName(AircraftDefinition def) { + return Component.translatableWithFallback("item.mcheli." + def.name(), def.displayName); + } + + /** + * {@code supplyAmmoToOtherAircraft}: an ammunition truck ({@code AmmoSupplyRange}) tops up the + * weapons of every vehicle within range that stands on something, a tenth of each weapon's + * maximum every two seconds. + */ + private void supplyAmmo(AircraftDefinition def) { + float range = def.ammoSupplyRange; + if (range <= 0.0F || ticksAlive % SupplyRules.AMMO_TRUCK_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + VehicleWeapons theirs = other.weapons(); + if (other == this || theirs == null || other.isDestroyed() || !other.canSupply()) { + continue; + } + for (WeaponSet set : theirs.sets()) { + int rounds = SupplyRules.roundsFromTruck(set.ammo() + set.restAmmo(), set.maxAmmo()); + if (rounds > 0) { + set.setRestAmmo(set.restAmmo() + rounds); + if (set.ammo() <= 0) { + set.reloadMagazine(); + } + } + } + } + } + + /** {@code repairOtherAircraft}: a repair vehicle ({@code RepairOtherVehicles}) mends those in range. */ + private void repairOthers(AircraftDefinition def) { + float range = def.repairOtherVehiclesRange; + if (range <= 0.0F || ticksAlive % SupplyRules.REPAIR_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + AircraftDefinition theirs = other.definition(); + if (other != this && theirs != null) { + other.repair(SupplyRules.repair(other.hp(), theirs.maxHp, def.repairOtherVehiclesValue)); + } + } + } + + /** 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 isLocalInstanceAuthoritative() && !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 + + /** + * Makes this a workshop test flight: it takes no damage, is not saved with the world, and goes + * away once its pilot gets out, putting them back where they started. + */ + public void startTestFlight(Vec3 start) { + testFlightStart = start; + } + + @Override + public boolean shouldBeSaved() { + return testFlightStart == null && super.shouldBeSaved(); + } + + @Override + public void tick() { + if (testFlightOver) { + discard(); + return; + } + rollO = roll(); + if (getHurtTime() > 0) { + setHurtTime(getHurtTime() - 1); + } + super.tick(); + interpolation.interpolate(); + ticksAlive++; + lastSeats.clear(); + remoteReturns.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 (!level().isClientSide()) { + supplyFuel(def); + supplyAmmo(def); + repairOthers(def); + burnFuel(def); + if (def.maxFuel > 0 && fuel() < def.maxFuel && !isDestroyed()) { + inventory.pourFuelCans(ticksAlive); + } + tickNightVision(); + tickStation(); + } + tickRopes(def); + + if (isSimulatingHere()) { + if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { + input.clear(); + } + if (isRepelling()) { + // {@code MCH_AircraftClientTickHandler}: the pilot holds a hover while the ropes are out. + input.throttleUp = false; + input.throttleDown = false; + input.left = false; + input.right = false; + } + if (!hasFuel()) { + // An empty tank: the throttle runs down by itself, as with the pilot's hand off it. + input.throttleUp = false; + } + 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 (!isLocalInstanceAuthoritative()) { + 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); + VehicleParticles.tick(this, 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) { + hurtServer((ServerLevel) level(), 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)) { + hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), crashDamage(previous.horizontalDistance())); + } + if (speed > VEHICLE_CRASH_SPEED && collisionCooldown == 0) { + // A blocked move can leave the hulls up to a tick of travel apart; when the vehicle + // stopped short this tick, look that far for what stopped it. + boolean stopped = velocity.length() < previous.length() * 0.5; + double reach = stopped ? 0.5 + previous.length() : 0.5; + for (AircraftEntity other : solidNeighbours(hullBounds().inflate(reach))) { + if (!hullsTouch(other, reach)) { + continue; + } + 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); + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), damage); + other.hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), 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) { + hurtServer((ServerLevel) level(), 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() || current == 0 && station != null) { + // A remote pilot stays at the controls; they are not really aboard. + 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); + } + + // UAV stations + + /** Whether a player at a station could take this drone's controls now. */ + public boolean canTakeRemotePilot() { + AircraftDefinition def = definition(); + return def != null && def.uav && !def.targetDrone && !isDestroyed() && isSeatFree(0); + } + + /** + * Seats {@code player}, who sits at {@code from}, at this drone's controls. The pilot is hidden + * and takes no harm while flying, since in the original they never left the station. Server only. + */ + public boolean takeRemotePilot(ServerPlayer player, UavStationEntity from) { + if (!canTakeRemotePilot()) { + return false; + } + pendingSeat = 0; + boolean seated = player.startRiding(this, true, true); + pendingSeat = EMPTY; + if (!seated) { + return false; + } + station = from.getUUID(); + stationReturn = from.returnPosition(); + entityData.set(DATA_STATION, from.getId()); + player.addTag(REMOTE_PILOT_TAG); + player.setInvisible(true); + player.setInvulnerable(true); + return true; + } + + /** Sends the remote pilot back to the station, if one is flying. Server only. */ + public void releaseRemotePilot() { + Entity pilot = occupant(0); + if (station != null && pilot != null) { + pilot.stopRiding(); + } + } + + /** The station this drone is flown from, as the client sees it. */ + public @Nullable UavStationEntity stationEntity() { + return level().getEntity(entityData.get(DATA_STATION)) instanceof UavStationEntity s ? s : null; + } + + /** Flat distance to the station this drone is flown from, or -1 without one. */ + public double stationDistance() { + UavStationEntity from = stationEntity(); + if (from == null) { + return -1.0; + } + double dx = getX() - from.getX(); + double dz = getZ() - from.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } + + /** Whether {@code entity} flies this drone from a station rather than sitting in it. */ + public boolean isRemotePilot(Entity entity) { + return (station != null || entityData.get(DATA_STATION) != 0) && occupant(0) == entity; + } + + private void endRemoteControl(Entity pilot) { + UavStationEntity from = level() instanceof ServerLevel server && station != null + && server.getEntity(station) instanceof UavStationEntity s ? s : null; + Vec3 home = from != null ? from.returnPosition() : stationReturn; + if (home != null) { + remoteReturns.put(pilot.getId(), home); + } + clearRemotePilot(pilot); + if (from != null) { + from.setLinked(false); + } + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + } + + /** Undoes what {@link #takeRemotePilot} did to the player. */ + public static void clearRemotePilot(Entity pilot) { + if (pilot.removeTag(REMOTE_PILOT_TAG)) { + pilot.setInvisible(false); + pilot.setInvulnerable(false); + } + } + + /** + * {@code updateUAV}: the link breaks when the station is gone, and flying out of its range + * loses the drone, as in the original. + */ + private void tickStation() { + if (station == null || !(level() instanceof ServerLevel server)) { + return; + } + Entity pilot = occupant(0); + if (pilot == null) { + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + return; + } + UavStationEntity from = server.getEntity(station) instanceof UavStationEntity s && s.isAlive() ? s : null; + if (from == null) { + pilot.stopRiding(); + return; + } + entityData.set(DATA_STATION, from.getId()); + if (!from.inRange(this) && !isDestroyed()) { + if (pilot instanceof Player player) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.lost"), true); + } + pilot.stopRiding(); + hurtServer(server, damageSources().fellOutOfWorld(), hp() + 10.0F); + } + } + + @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); + } + // Coming back aboard after a reload, a remote pilot included. + Integer saved = savedSeats.get(passenger.getUUID()); + if (saved != null && isSeatFree(saved)) { + return true; + } + // Cargo only boards through a rack, never into a crew seat. + return !(passenger instanceof AircraftEntity || passenger instanceof ContainerEntity) && 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) { + if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(passenger); + } + lastSeats.put(passenger.getId(), seat); + seatIds[seat] = EMPTY; + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onLeft(seat); + } + } + if (seat == 0) { + input.clear(); + if (station != null) { + endRemoteControl(passenger); + } + if (testFlightStart != null) { + remoteReturns.put(passenger.getId(), testFlightStart); + testFlightOver = true; + } + } + } + + @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); + } else if (def != null && passenger instanceof ContainerEntity container) { + RackDefinition rack = def.rack(seatOf(passenger)); + container.setYRot(getYRot() + (rack != null ? rack.yaw() : 0.0F)); + container.setDeltaMovement(Vec3.ZERO); + } + } + + @Override + public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + Vec3 home = remoteReturns.remove(passenger.getId()); + if (home != null) { + return home; + } + 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); + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + ItemStack held = player.getItemInHand(hand); + if (held.getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (definition() != null && (held.getItem() instanceof FuelItem || held.getItem() instanceof WrenchItem)) { + if (level().isClientSide()) { + return InteractionResult.SUCCESS; + } + return held.getItem() instanceof FuelItem ? FuelItem.pour(held, this, player) : WrenchItem.useOn(held, this, player, hand); + } + InteractionResult result = super.interact(player, hand); + AircraftDefinition def = definition(); + if (result == InteractionResult.PASS && player.isSecondaryUseActive() && def != null && def.cargoRows() > 0 + && !isDestroyed() && player.getVehicle() != this) { + // Sneaking opens the cargo chest from outside, as in the original. + if (player instanceof ServerPlayer server) { + openCargo(server); + } + return InteractionResult.SUCCESS; + } + if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { + return result; + } + 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(); + boardNearbyMobs(); + } + } + return InteractionResult.SUCCESS; + } + + // Racks + + /** Loads {@code cargo} into the given rack seat. Server only. */ + private boolean loadCargo(Entity cargo, int seat) { + if (!isSeatFree(seat) || cargo.isPassenger()) { + return false; + } + pendingSeat = seat; + boolean loaded = cargo.startRiding(this, true, 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; + } + + /** Whether rack {@code seat} carries containers, {@code AddRack} naming {@code container}. */ + public boolean acceptsContainer(ContainerEntity container, int seat) { + AircraftDefinition def = definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + return rack != null && !isPassenger() && !container.isPassenger() + && rack.accepts(ContainerEntity.CARGO_NAME, ContainerEntity.CARGO_NAME); + } + + 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 or container 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()); + boolean loaded = false; + for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { + if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + loaded = true; + break; + } + } + if (!loaded) { + for (ContainerEntity container : level().getEntitiesOfClass(ContainerEntity.class, area)) { + if (acceptsContainer(container, seat) && loadCargo(container, 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.snapTo(at.x, at.y, at.z, getYRot() + rack.yaw(), 0.0F); + // As in the original, with no ground in the 20 blocks below it goes down under a canopy. + if (!(cargo instanceof AircraftEntity) && !hasBlockBelow(3, 20, false)) { + ParachuteEntity.open(level(), cargo, ParachuteEntity.Kind.DROP); + } + 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(); + snapTo(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); + } + + /** + * {@code ejectSeat}: throws the occupant clear of the aircraft in an ejection seat, whose + * parachute opens once it starts to fall. Each ejection takes a parachute from the parachute + * slots of the vehicle screen; without one the seat stays put. Creative players need none. + * Server only. + */ + public void eject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + if (def == null || !canEject(entity)) { + return; + } + boolean creative = entity instanceof Player player && player.getAbilities().instabuild; + if (!creative && !inventory.takeParachute()) { + if (entity instanceof ServerPlayer player) { + ModSounds.playTo(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.no_parachute"), true); + } + 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.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.EJECTION_SEAT); + if (canopy != null && !canopy.status()) { + canopy.set(true, false); + modeSwitchCooldown = 40; + } + ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); + } + + // Search lights + + public boolean isSearchLightOn() { + AircraftDefinition def = definition(); + return def != null && !def.searchLights.isEmpty() && entityData.get(DATA_SEARCH_LIGHT); + } + + /** {@code canSwitchSearchLight}: the pilot or the first crew seat, in vehicles that have one. */ + public boolean canSwitchSearchLight(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && !def.searchLights.isEmpty() && seat >= 0 && seat <= 1; + } + + /** Server only. */ + public void toggleSearchLight(Entity entity) { + if (canSwitchSearchLight(entity)) { + entityData.set(DATA_SEARCH_LIGHT, !entityData.get(DATA_SEARCH_LIGHT)); + } + } + + /** + * Where a turning search light points: the first crew seat's view, else the pilot's, as + * {@code renderLight} took them. Null with nobody aboard to aim it. + */ + public @Nullable Entity searchLightAimer() { + Entity crew = occupant(1); + return crew != null ? crew : occupant(0); + } + + // Troops: parachute drops and ropes + + public boolean isRepelling() { + return entityData.get(DATA_REPELLING); + } + + /** How far the ropes hang below their hooks, as a negative number. */ + public float ropeLength() { + return ropeLength; + } + + /** Where rope {@code index} hangs from, in the world. */ + public Vec3 ropePosition(int index) { + AircraftDefinition def = definition(); + return def != null && index >= 0 && index < def.repellingHooks.size() ? toWorld(def.repellingHooks.get(index).position()) : position(); + } + + /** + * {@code onUpdate_Repelling} and {@code onUpdate_UnmountCrew}: the ropes run out towards the + * ground, then the mobs aboard slide down them one per hook interval; a parachute drop lets one + * passenger out per {@code MobDropOption} interval while the hatch is open. + */ + private void tickRopes(AircraftDefinition def) { + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + int clear = airBelow(50); + if (ropeLength > -50.0F && ropeLength > -clear) { + ropeLength -= level().isClientSide() ? 0.3F : 0.25F; + } + } else { + ropeLength = 0.0F; + } + } + if (level().isClientSide()) { + return; + } + if (isRepelling() && isDestroyed()) { + entityData.set(DATA_REPELLING, false); + } + if (!isRepelling()) { + repellingTicks = 0; + } else if (repellingTicks < 60) { + repellingTicks++; + } else { + for (int rope = 0; rope < def.repellingHooks.size(); rope++) { + if (ticksAlive % def.repellingHooks.get(rope).interval() != 0) { + continue; + } + for (int seat = 1; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + rappel(mob, rope); + break; + } + } + } + } + if (droppingTroops) { + if (hasBlockBelow(3, 10, false) || isDestroyed()) { + droppingTroops = false; + } else if ((hatch == null || hatch.isOn()) && ticksAlive % def.dropInterval == 0 && !dropNextTroop(def)) { + droppingTroops = false; + } + } + } + + /** Air blocks straight below, up to {@code max}. */ + private int airBelow(int max) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + int i = 0; + while (i < max && pos.getY() - i > level().getMinY() && level().getBlockState(pos.setY(blockPosition().getY() - i - 1)).isAir()) { + i++; + } + return i; + } + + /** + * {@code canStartRepelling}: a helicopter in hover mode, level and holding still. The original + * compared against where it was a few ticks before. + */ + private boolean canStartRepelling() { + return this instanceof HelicopterEntity && isHoveringMode() && Math.abs(getXRot()) < 3.0F && Math.abs(roll()) < 3.0F + && lastTickPos != null && position().distanceTo(lastTickPos) < 0.05; + } + + /** + * The pilot's drop key, {@code unmountCrew}: with ropes, lets them down from a steady hover + * or pulls them up again; with {@code EnableParachuting}, starts or stops dropping the mobs + * aboard under parachutes; otherwise, or close to the ground, simply lets the mobs out. + * Server only. + * + * @return false if nothing could be done, so the pilot hears it + */ + public boolean dropCrew() { + AircraftDefinition def = definition(); + if (def == null || isDestroyed()) { + return false; + } + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + entityData.set(DATA_REPELLING, false); + return true; + } + if (hasBlockBelow(3, 4, false)) { + return unloadMobs(); + } + if (!canStartRepelling()) { + return false; + } + entityData.set(DATA_REPELLING, true); + repellingTicks = 0; + return true; + } + if (droppingTroops) { + droppingTroops = false; + return true; + } + if (def.parachuting && !hasBlockBelow(3, 10, false)) { + droppingTroops = true; + if (hatch != null && hatch.isOff()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } + return true; + } + return unloadMobs(); + } + + /** Lets every mob in a crew seat out, as in {@code unmountCrew(false)}. */ + private boolean unloadMobs() { + AircraftDefinition def = definition(); + boolean any = false; + for (int seat = 1; def != null && seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + mob.stopRiding(); + any = true; + } + } + return any; + } + + /** The next mob behind the second seat leaves at the drop point under a parachute. */ + private boolean dropNextTroop(AircraftDefinition def) { + for (int seat = 2; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + parachuteOut(mob, def); + return true; + } + } + return false; + } + + private void parachuteOut(Entity entity, AircraftDefinition def) { + Vec3 out = toWorld(def.dropPosition); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement()); + entity.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.DROP); + } + + /** + * A passenger's own drop key, {@code MCH_ClientSeatTickHandler}: slide down a rope once the + * ropes are out, or jump with a parachute from behind the second seat of a vehicle with + * {@code EnableParachuting} and room below. Server only. + * + * @return false if neither is possible right now + */ + public boolean jumpOut(Player player) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (def == null || seat <= 0 || seat >= def.seatCount() || isDestroyed()) { + return false; + } + if (isRepelling() && repellingTicks > 50 && !def.repellingHooks.isEmpty()) { + lastRope = (lastRope + 1) % def.repellingHooks.size(); + rappel(player, lastRope); + return true; + } + if (def.parachuting && seat >= 2 && !hasBlockBelow(3, 13, false)) { + parachuteOut(player, def); + return true; + } + return false; + } + + /** {@code unmountEntityRepelling}: two blocks under the hook, onto a rope rider that lets them down. */ + private void rappel(Entity entity, int rope) { + Vec3 at = ropePosition(rope).add(0.0, -2.0, 0.0); + entity.stopRiding(); + entity.teleportTo(at.x, at.y, at.z); + entity.setDeltaMovement(Vec3.ZERO); + entity.resetFallDistance(); + RappelEntity rider = new RappelEntity(ModEntities.RAPPEL.get(), level()); + rider.attach(this, rope); + rider.setPos(at); + level().addFreshEntity(rider); + entity.startRiding(rider, true, true); + lastRope = rope; + } + + /** + * {@code mountMobToSeats}: when the pilot climbs in, mobs standing close by take the free crew + * seats, so villagers and the like can be flown somewhere. Server only. + */ + private void boardNearbyMobs() { + AircraftDefinition def = definition(); + if (def == null || def.uav) { + return; + } + for (Mob mob : level().getEntitiesOfClass(Mob.class, getBoundingBox().inflate(3.0, 2.0, 3.0), m -> !m.isPassenger() && !m.isVehicle())) { + for (int seat = 1; seat < def.seatCount(); seat++) { + if (isSeatFree(seat)) { + pendingSeat = seat; + mob.startRiding(this, true, true); + pendingSeat = EMPTY; + break; + } + } + } + } + + // 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(@Nullable Entity other) { + 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.hull()) { + Optional hit = clip(hullBox(box).inflate(inflate), from, to); + if (hit.isPresent() && from.distanceToSqr(hit.get()) < bestDistance) { + best = hit; + bestDistance = from.distanceToSqr(hit.get()); + hitBoxFactor = box.damageFactor(); + } + } + } + return best; + } + + /** + * One {@code BoundingBox} in world space. Like the original's, it stays axis aligned: the + * centre turns with the hull, the box itself does not. + */ + private AABB hullBox(BoundingBoxDefinition box) { + Vec3 c = toWorld(box.offset()); + double hw = box.width() / 2.0; + double hh = box.height() / 2.0; + return new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw); + } + + /** The whole hull: the main hit box, every {@code BoundingBox} and the generated boxes, in world space. */ + public List hullBoxes() { + AircraftDefinition def = definition(); + if (def == null || def.hull().isEmpty()) { + return List.of(getBoundingBox()); + } + List boxes = new ArrayList<>(def.hull().size() + 1); + boxes.add(getBoundingBox()); + for (BoundingBoxDefinition box : def.hull()) { + boxes.add(hullBox(box)); + } + return boxes; + } + + /** The smallest box around the whole hull. */ + private AABB hullBounds() { + AABB bounds = getBoundingBox(); + AircraftDefinition def = definition(); + if (def != null) { + for (BoundingBoxDefinition box : def.hull()) { + bounds = bounds.minmax(hullBox(box)); + } + } + return bounds; + } + + /** Whether any part of this hull touches any part of {@code other}'s, grown by {@code inflate}. */ + private boolean hullsTouch(AircraftEntity other, double inflate) { + // Hulls generated from big models run to a thousand boxes, so skip ours that are nowhere near. + AABB around = other.hullBounds().inflate(inflate); + List theirs = null; + for (AABB mine : hullBoxes()) { + AABB grown = mine.inflate(inflate); + if (!grown.intersects(around)) { + continue; + } + if (theirs == null) { + theirs = other.hullBoxes(); + } + for (AABB box : theirs) { + if (grown.intersects(box)) { + return true; + } + } + } + return false; + } + + /** + * Vehicles whose hulls block this one: everything nearby except its own carrier or cargo, and + * vehicles either of the two could load onto a rack, which have to drive into each other. + */ + private List solidNeighbours(AABB area) { + return level().getEntitiesOfClass(AircraftEntity.class, area.inflate(HULL_REACH), other -> other != this && !other.isRemoved() + && other.definition() != null && !other.isPassenger() && !isPassengerOfSameVehicle(other) + && other.getVehicle() != this && getVehicle() != other && !canCarry(other) && !other.canCarry(this)); + } + + /** Whether a free rack of this vehicle would take {@code cargo}. */ + private boolean canCarry(AircraftEntity cargo) { + AircraftDefinition def = definition(); + if (def == null) { + return false; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (acceptsCargo(cargo, seat)) { + return true; + } + } + return false; + } + + /** + * Not in the original, where only the main box at a vehicle's feet collided: the whole hull + * blocks other vehicles. A move that would push this hull into another vehicle's is undone, + * sideways first and then completely, so a helicopter can also set down on a parked truck. + * Hulls that already overlapped, say after two vehicles were placed into each other, may move + * apart. + */ + @Override + public void move(MoverType type, Vec3 delta) { + if (type != MoverType.SELF || isPassenger() || definition() == null) { + super.move(type, delta); + return; + } + List near = solidNeighbours(hullBounds().expandTowards(delta)); + if (near.isEmpty()) { + super.move(type, delta); + return; + } + List overlapping = new ArrayList<>(); + for (AircraftEntity other : near) { + if (hullsTouch(other, 0.0)) { + overlapping.add(other); + } + } + Vec3 before = position(); + super.move(type, delta); + if (!entersHull(near, overlapping)) { + return; + } + Vec3 m = getDeltaMovement(); + setPos(before.x, getY(), before.z); + setDeltaMovement(0.0, m.y, 0.0); + if (entersHull(near, overlapping)) { + setPos(before); + setDeltaMovement(Vec3.ZERO); + } + } + + private boolean entersHull(List near, List overlapping) { + for (AircraftEntity other : near) { + if (!overlapping.contains(other) && hullsTouch(other, 0.0)) { + return true; + } + } + return false; + } + + /** 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + float boxFactor = consumeHitBoxFactor(); + if (testFlightStart != null || isRemoved() || isInvulnerableToBase(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() || isHeavy() && isNearGround(3)) { + 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(level, 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; + 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; + } + if (isHeavy()) { + startCookOff(null); + return; + } + exploded = true; + AircraftDefinition def = definition(); + float size = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + Vec3 at = def != null && def.fuelTankPosition != null ? toWorld(def.fuelTankPosition) : position(); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, null, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + if (!isInWater()) { + Explosions.crashFireball(level, at.x, at.y, at.z, size); + } + } + + /** + * Airliners, bombers and transports ({@code MaxFuel} of {@value HEAVY_FUEL} or more): like a + * boat, they go up in three blasts rather than one fireball. + */ + private boolean isHeavy() { + AircraftDefinition def = definition(); + return def != null && def.maxFuel >= HEAVY_FUEL; + } + + /** 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, or a heavy aircraft wrecked on the ground, does not go up in + * one crash fireball; its fuel and magazine cook off in three blasts over about five seconds, + * then a hull in the water 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 { + // A boat or a flying boat goes under; a heavy aircraft on land stays where it burns. + if (isInWater() || waterDepth() > 0.0) { + 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; + crashSpin(); + 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(); + } + } + + /** + * Not in the original: a shot down plane rolls into a nose-down spiral and a helicopter spins + * with some wobble, until it is close to the ground. Tanks and cars just stop. + */ + private void crashSpin() { + if (!(this instanceof HelicopterEntity) && !(this instanceof PlaneEntity) || isNearGround(3) || isInWater()) { + return; + } + int direction = CrashSpin.direction(getId()); + int ticks = spinTicks++; + if (this instanceof HelicopterEntity) { + setYRot(getYRot() + CrashSpin.helicopterYawRate(ticks, direction)); + setXRot(Mth.approach(getXRot(), CrashSpin.helicopterWobblePitch(ticks, getId()), 2.0F)); + roll = Mth.approach(roll, CrashSpin.helicopterWobbleRoll(ticks, getId()), 3.0F); + } else { + setYRot(getYRot() + CrashSpin.planeYawRate(ticks, direction)); + setXRot(CrashSpin.planePitch(getXRot())); + roll = AircraftRotation.wrap(roll + CrashSpin.planeRollRate(ticks, direction)); + } + } + + /** {@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 = def.enginePosition != null ? toWorld(def.enginePosition) : 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); + } + } + + /** A vehicle that is destroyed, packed up or killed spills what it carried; one that unloads keeps it. */ + @Override + public void remove(RemovalReason reason) { + if (reason.shouldDestroy() && !level().isClientSide() && !isRemoved()) { + inventory.dropAll(); + } + super.remove(reason); + } + + @Override + protected Item getDropItem() { + AircraftDefinition def = definition(); + return def != null ? ModItems.forKind(def.kind()) : Items.AIR; + } + + @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()); + } + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putString("Type", typeName()); + output.putString("Texture", texture()); + output.putInt("Hp", hp()); + output.putInt("Fuel", fuel()); + output.putFloat("Roll", roll); + output.putBoolean("Hovering", isHoveringMode()); + output.putBoolean("GunnerMode", isGunnerMode()); + output.putInt("Parts", partBits()); + output.putInt("DespawnCount", despawnCount); + output.putInt("CookOff", cookOffBlasts); + output.putBoolean("Sinking", entityData.get(DATA_SINKING)); + output.putBoolean("SearchLight", entityData.get(DATA_SEARCH_LIGHT)); + if (weapons != null) { + output.putIntArray("WeaponsAmmo", weapons.saveAmmo()); + } + inventory.save(output); + if (station != null && stationReturn != null) { + output.store("Station", UUIDUtil.CODEC, station); + output.store("StationReturn", Vec3.CODEC, stationReturn); + } + ValueOutput.TypedOutputList seats = output.list("Seats", Codec.STRING); + for (int i = 0; i < seatIds.length; i++) { + Entity e = occupant(i); + seats.add(e != null ? e.getUUID().toString() : ""); + } + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + entityData.set(DATA_TYPE, input.getStringOr("Type", "")); + entityData.set(DATA_TEXTURE, input.getStringOr("Texture", "")); + entityData.set(DATA_PARTS, input.getIntOr("Parts", 0)); + AircraftDefinition def = definition(); + entityData.set(DATA_HP, input.getIntOr("Hp", def != null ? def.maxHp : 1)); + // 1.0.0 and the earlier betas had no fuel; their vehicles come back with a full tank. + entityData.set(DATA_FUEL, input.getIntOr("Fuel", def != null ? def.maxFuel : 0)); + roll = input.getFloatOr("Roll", 0.0F); + setHovering(input.getBooleanOr("Hovering", false)); + entityData.set(DATA_GUNNER_MODE, input.getBooleanOr("GunnerMode", false)); + despawnCount = input.getIntOr("DespawnCount", 0); + if (hp() <= 0 && despawnCount <= 0) { + despawnCount = WRECK_TICKS; + } + cookOffBlasts = input.getIntOr("CookOff", 0); + cookOffDelay = cookOffBlasts > 0 ? 20 : 0; + entityData.set(DATA_SINKING, input.getBooleanOr("Sinking", false)); + entityData.set(DATA_SEARCH_LIGHT, input.getBooleanOr("SearchLight", false)); + exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); + input.getIntArray("WeaponsAmmo").ifPresent(ammo -> { + if (weapons != null) { + weapons.loadAmmo(ammo); + } else { + savedAmmo = ammo; + } + }); + inventory.load(input); + station = input.read("Station", UUIDUtil.CODEC).orElse(null); + stationReturn = input.read("StationReturn", Vec3.CODEC).orElse(null); + savedSeats.clear(); + List seats = input.listOrEmpty("Seats", Codec.STRING).stream().toList(); + for (int i = 0; i < seats.size(); i++) { + if (!seats.get(i).isEmpty()) { + try { + savedSeats.put(UUID.fromString(seats.get(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; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java new file mode 100644 index 00000000..eda5ed7b --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java new file mode 100644 index 00000000..4aaab44d --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java @@ -0,0 +1,193 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.UUIDUtil; +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.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 net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * A tow chain, {@code MCH_EntityChain}: it hangs above the vehicle that pulls and drags the towed + * one along, letting it drift up to the chain's length away and never more than two blocks past + * that. It goes when either end does. + */ +public class ChainEntity extends Entity { + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 15; + /** Ticks a chain loaded from disk waits for both ends to load before it gives up. */ + private static final int SEARCH_TICKS = 50; + + private static final EntityDataAccessor DATA_TOW = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TOWED = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + + private @Nullable UUID towId; + private @Nullable UUID towedId; + private @Nullable Entity tow; + private @Nullable Entity towed; + private int length = 4; + private int searchTicks = SEARCH_TICKS; + + public ChainEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + /** Chains {@code towed} to {@code tow}, at the distance they are apart now. Server only. */ + public static ChainEntity connect(Level level, Entity towed, Entity tow) { + ChainEntity chain = new ChainEntity(ModEntities.CHAIN.get(), level); + chain.setPos(tow.position().add(towed.position()).scale(0.5)); + chain.length = Mth.clamp((int) towed.distanceTo(tow), MIN_LENGTH, MAX_LENGTH); + chain.attach(towed, tow); + level.addFreshEntity(chain); + ModSounds.playAt(chain.tow, "chain_ct", 1.0F, 1.0F); + return chain; + } + + /** Whether {@code entity} can pull the other end: only vehicles can. */ + public static boolean canTow(Entity entity) { + return entity instanceof AircraftEntity; + } + + /** + * Only a vehicle pulls. Hooked the other way round, a container would hold a helicopter down, + * so the ends swap whichever order they were clicked in (and chains saved that way are fixed + * when they load). + */ + private void attach(Entity towed, Entity tow) { + if (!canTow(tow) && canTow(towed)) { + Entity swap = tow; + tow = towed; + towed = swap; + } + this.towed = towed; + this.tow = tow; + this.towedId = towed.getUUID(); + this.towId = tow.getUUID(); + entityData.set(DATA_TOWED, towed.getId()); + entityData.set(DATA_TOW, tow.getId()); + searchTicks = 0; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_TOW, -1); + builder.define(DATA_TOWED, -1); + } + + /** The end that pulls; on a client, as far as it has loaded. */ + public @Nullable Entity tow() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOW)) : tow; + } + + /** The end that is pulled. */ + public @Nullable Entity towed() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOWED)) : towed; + } + + /** Whether {@code entity} is either end of this chain. */ + public boolean holds(Entity entity) { + return entity == tow() || entity == towed(); + } + + /** Takes the chain off, with the sound of it coming loose. Server only. */ + public void disconnect() { + ModSounds.playAt(this, "chain_ct", 1.0F, 1.0F); + discard(); + } + + /** The chain on {@code entity}, at either end, if there is one within reach. */ + public static @Nullable ChainEntity on(Entity entity) { + for (ChainEntity chain : entity.level().getEntitiesOfClass(ChainEntity.class, entity.getBoundingBox().inflate(25.0))) { + if (chain.holds(entity)) { + return chain; + } + } + return null; + } + + @Override + public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel server)) { + return; + } + if (searchTicks > 0) { + searchTicks--; + Entity foundTowed = towedId != null ? server.getEntity(towedId) : null; + Entity foundTow = towId != null ? server.getEntity(towId) : null; + if (foundTowed != null && foundTow != null) { + attach(foundTowed, foundTow); + } + return; + } + if (tow == null || towed == null || tow.isRemoved() || towed.isRemoved() || tow == towed) { + disconnect(); + return; + } + towed.resetFallDistance(); + setPos(tow.getX(), tow.getY() + 2.0, tow.getZ()); + pull(tow, towed); + } + + /** {@code updateTowingEntityPosRot}: turns the towed end to face along the chain and drags it in. */ + private void pull(Entity tow, Entity towed) { + Vec3 d = towed.position().subtract(tow.position()); + double dist = d.length(); + if (dist <= length) { + return; + } + towed.setYRot((float) Math.toDegrees(Math.atan2(d.z, d.x)) + 90.0F); + double slack = dist - length; + towed.setDeltaMovement(towed.getDeltaMovement().subtract(d.scale(0.001 / slack))); + double max = length + 2.0; + if (dist > max) { + Vec3 at = tow.position().add(d.scale(max / dist)); + towed.setPos(at.x, at.y, at.z); + } + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + towId = input.read("Tow", UUIDUtil.CODEC).orElse(null); + towedId = input.read("Towed", UUIDUtil.CODEC).orElse(null); + length = Mth.clamp(input.getIntOr("Length", 4), MIN_LENGTH, MAX_LENGTH); + searchTicks = SEARCH_TICKS; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + if (towId != null && towedId != null) { + output.store("Tow", UUIDUtil.CODEC, towId); + output.store("Towed", UUIDUtil.CODEC, towedId); + } + output.putInt("Length", length); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java new file mode 100644 index 00000000..28f14851 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +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) { + } + + @Override + public void onPackSound(PackSoundPacket 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); + + void onPackSound(PackSoundPacket packet); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java new file mode 100644 index 00000000..c3ee5bc9 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java @@ -0,0 +1,152 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.network.chat.Component; +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.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A cargo container, {@code MCH_EntityContainer}: a crate with a large chest's worth of room that + * sits on the ground, floats, can be pushed around, and rides the racks of the vehicles that carry + * {@code container}. Dropped from a rack high enough it comes down under a parachute. + */ +public class ContainerEntity extends Entity { + /** What racks call it in their {@code AddRack} lines. */ + public static final String CARGO_NAME = "container"; + private static final int SLOTS = 54; + /** Punches add up; the original broke it past 40 with 20 per point of damage. */ + private static final int BREAK_DAMAGE = 40; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final SimpleContainer items = new SimpleContainer(SLOTS); + private int damageTaken; + + public ContainerEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide() || isPassenger()) { + return; + } + if (damageTaken > 0) { + damageTaken--; + } + Vec3 m = getDeltaMovement(); + double my = m.y; + if (isInWater() || isInLava()) { + // Bobs up to the surface rather than sinking, as the original's boat physics did. + my = (my < 0.0 ? my / 2.0 : my) + (isEyeInFluid(FluidTags.WATER) ? 0.04 : 0.007); + } else { + my -= 0.04; + } + double mx = m.x; + double mz = m.z; + if (onGround()) { + mx *= 0.9; + mz *= 0.9; + } + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + if (player.getItemInHand(hand).getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (player.isSecondaryUseActive()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider((id, inventory, p) -> ChestMenu.sixRows(id, inventory, items), + Component.translatable("entity.mcheli.container"))); + } + return InteractionResult.SUCCESS; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || !(source.getEntity() instanceof Player player)) { + // Only players take it apart; explosions and stray rounds leave the cargo alone. + return false; + } + ModSounds.playAt(this, "hit", 1.0F, 1.3F); + damageTaken += (int) (damage * 20.0F); + boolean creative = player.getAbilities().instabuild; + if (creative || damageTaken > BREAK_DAMAGE) { + if (!creative) { + spawnAtLocation(level, ModItems.CONTAINER.get()); + } + Containers.dropContents(level, this, items); + discard(); + } + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean isPushable() { + return !isPassenger(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return !isPassenger(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.CONTAINER.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + items.clearContent(); + ContainerHelper.loadAllItems(input, items.getItems()); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + ContainerHelper.saveAllItems(output, items.getItems(), false); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java new file mode 100644 index 00000000..6b25a388 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java new file mode 100644 index 00000000..53ba6b32 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java @@ -0,0 +1,213 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.weapon.CasStrike; +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.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The ground laser target designator, {@code MCH_EntityGLTD}: a tripod a player sits behind. Its + * sight looks up to 70 degrees either side of where it was set up, zooms and has night vision, + * and marking a spot calls in an A-10 for three gun runs over it. + */ +public class GltdEntity extends Entity { + /** How far the sight turns from the tripod's heading, either way, and up or down. */ + public static final float VIEW_LIMIT = 70.0F; + /** The CAS weapon the A-10s fire; the original hard coded it too. */ + private static final String WEAPON = "a10gau8"; + /** Added to the weapon's own {@code Delay} between two calls. */ + private static final int EXTRA_WAIT = 150; + private static final double SIGHT_RANGE = 300.0; + + private static final EntityDataAccessor DATA_WAIT = SynchedEntityData.defineId(GltdEntity.class, EntityDataSerializers.INT); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final CasStrike strike = new CasStrike(WEAPON); + private boolean nightVision; + + public GltdEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WAIT, 0); + } + + /** Ticks until the next A-10 can be called; the sight shows it. */ + public int waitTicks() { + return entityData.get(DATA_WAIT); + } + + /** The eye of the sight, a little in front of and above the tripod. */ + public Vec3 sightPosition(float partialTicks) { + float yaw = getYRot(partialTicks) * Mth.DEG_TO_RAD; + Vec3 pos = getPosition(partialTicks); + return new Vec3(pos.x - Mth.sin(yaw) * 0.6, pos.y + 0.7, pos.z + Mth.cos(yaw) * 0.6); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.04, 0.0); + if (onGround()) { + m = m.scale(0.5); + } + move(MoverType.SELF, m); + setDeltaMovement(m.x * 0.99, m.y * 0.95, m.z * 0.99); + + if (waitTicks() > 0) { + entityData.set(DATA_WAIT, waitTicks() - 1); + } + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (def != null && level() instanceof ServerLevel server) { + strike.tick(server, def); + } + Entity viewer = getFirstPassenger(); + if (viewer == null) { + nightVision = false; + } else if (nightVision) { + NightVision.topUp(viewer); + } + } + + /** + * {@code useCurrentWeapon}: marks the block {@code player} looks at through the sight and calls + * the A-10 on it. Server only. + * + * @return whether the strike was called; the sight may be waiting, or see nothing in range + */ + public boolean callStrike(ServerPlayer player) { + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (getFirstPassenger() != player || waitTicks() > 0 || def == null || !(level() instanceof ServerLevel server)) { + return false; + } + Vec3 from = sightPosition(1.0F); + Vec3 to = from.add(player.getLookAngle().scale(SIGHT_RANGE)); + BlockHitResult hit = server.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + strike.start(hit.getLocation(), player.getYRot(), this, player); + entityData.set(DATA_WAIT, def.delay + EXTRA_WAIT); + ModSounds.playAt(server, getX(), getY(), getZ(), "gltd", 0.5F, 1.0F); + if (def.sound != null) { + ModSounds.playTo(player, def.sound, 1.0F, 1.0F); + } + return true; + } + + public void setNightVision(Player player, boolean on) { + if (getFirstPassenger() != player) { + return; + } + nightVision = on; + if (!on) { + NightVision.end(player); + } + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + if (player.isSecondaryUseActive() || isVehicle()) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + player.setYRot(getYRot()); + player.setXRot(0.0F); + player.startRiding(this); + } + return InteractionResult.SUCCESS; + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (!level().isClientSide() && nightVision) { + NightVision.end(passenger); + nightVision = false; + } + } + + /** The viewer sits behind the tripod. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, -0.5).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + /** The original clamped the viewer's head the same way every tick. */ + @Override + public void onPassengerTurned(Entity passenger) { + float yaw = getYRot() + Mth.clamp(Mth.wrapDegrees(passenger.getYRot() - getYRot()), -VIEW_LIMIT, VIEW_LIMIT); + passenger.setYRot(yaw); + passenger.setYHeadRot(yaw); + passenger.setXRot(Mth.clamp(passenger.getXRot(), -VIEW_LIMIT, VIEW_LIMIT)); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved()) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + if (!creative) { + spawnAtLocation(level, ModItems.GLTD.get()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), "hit", 1.0F, 1.0F); + discard(); + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.GLTD.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java new file mode 100644 index 00000000..30290141 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java new file mode 100644 index 00000000..957bf9ef --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -0,0 +1,553 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +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.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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.
  • + *
  • Blades fold and unfold one after another at the same pace on both sides, from the + * synced fold state, instead of each client stepping its own copy.
  • + *
+ */ +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 static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + + private double currentSpeed = MIN_SPEED; + private float rotorAngle; + public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; + + 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; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, input.getBooleanOr("FoldBlade", false) ? FOLDED : UNFOLDED); + } + + /** 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() && canUseBlades()) { + setHovering(true); + } + } + + private boolean isLevelEnough() { + return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; + } + + @Override + public boolean canSwitchGunnerMode() { + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); + } + + @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; + } + // Standing on its skids or wheels a helicopter stays level; the mouse only tilts it in the air. + if (!isHovering() && !onGround()) { + 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 (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } + 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; + tickBlades(def); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 00000000..f52c4227 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 00000000..f7a7db39 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java @@ -0,0 +1,188 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A parachute with someone hanging under it, {@code MCH_EntityParachute}. It sinks at about two + * blocks a second, the rider steers it with the forward key, and it folds away a moment after it + * touches the ground or the water. + */ +public class ParachuteEntity extends Entity { + /** What the parachute looks like and how it opens, the original's model type. */ + public enum Kind { + /** From the parachute item: open from the start. */ + ITEM, + /** An ejection seat: the rider falls in the seat and the canopy opens once it drops. */ + EJECTION_SEAT, + /** Troops dropped from a rack or a cargo door: open from the start, no seat. */ + DROP + } + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(ParachuteEntity.class, EntityDataSerializers.INT); + private static final double MAX_SPEED = 0.35; + private static final double MIN_STEER = 0.07; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private double steer = MIN_STEER; + private int onGroundCount; + /** Who boards on the next server tick; boarding in the same tick as the spawn can be lost. */ + private @Nullable Entity user; + + public ParachuteEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Opens a parachute above {@code user}, carrying over their speed, and puts them under it. + * Server only. + */ + public static ParachuteEntity open(Level level, Entity user, Kind kind) { + ParachuteEntity parachute = new ParachuteEntity(ModEntities.PARACHUTE.get(), level); + double lift = kind == Kind.ITEM ? 3.5 : 0.0; + parachute.setPos(user.getX(), user.getY() + lift, user.getZ()); + parachute.setYRot(user.getYRot()); + parachute.setDeltaMovement(user.getDeltaMovement()); + parachute.entityData.set(DATA_KIND, kind.ordinal()); + parachute.user = user; + user.resetFallDistance(); + level.addFreshEntity(parachute); + return parachute; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, Kind.ITEM.ordinal()); + } + + public Kind kind() { + Kind[] kinds = Kind.values(); + int i = entityData.get(DATA_KIND); + return i >= 0 && i < kinds.length ? kinds[i] : Kind.ITEM; + } + + /** An ejection seat only opens its canopy once it has started to fall. */ + public boolean isOpen() { + return kind() != Kind.EJECTION_SEAT || getDeltaMovement().y < -0.1; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + if (user != null) { + if (!user.isPassenger() && user.isAlive()) { + user.startRiding(this, true, true); + setYRot(user.getYRot()); + } + user = null; + } + Entity rider = getFirstPassenger(); + if (rider == null && tickCount > 20 || isInWater()) { + ejectPassengers(); + discard(); + return; + } + if (rider != null) { + rider.resetFallDistance(); + } + simulate(rider); + } + + /** {@code onUpdateServer}. */ + private void simulate(@Nullable Entity rider) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevSpeed = Math.sqrt(mx * mx + mz * mz); + boolean seatLaunch = kind() == Kind.EJECTION_SEAT && tickCount < 20; + my -= onGround() || seatLaunch ? 0.01 : 0.03; + + if (isOpen()) { + if (rider instanceof ServerPlayer player && player.getLastClientInput().forward()) { + double yaw = Math.toRadians(rider.getYRot()); + mx += -Math.sin(yaw) * steer * 0.05; + mz += Math.cos(yaw) * steer * 0.05; + } + double speed = Math.sqrt(mx * mx + mz * mz); + if (speed > MAX_SPEED) { + mx *= MAX_SPEED / speed; + mz *= MAX_SPEED / speed; + speed = MAX_SPEED; + } + // Holding course builds up speed; letting go settles back to a slow drift. + if (speed > prevSpeed) { + steer = Math.min(steer + (MAX_SPEED - steer) / 35.0, MAX_SPEED); + } else { + steer = Math.max(steer - (steer - MIN_STEER) / 35.0, MIN_STEER); + } + } + + if (onGround() && ++onGroundCount > 5) { + ejectPassengers(); + discard(); + return; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + my *= seatLaunch ? 0.95 : 0.9; + double drag = isOpen() ? 0.95 : 0.99; + setDeltaMovement(mx * drag, my, mz * drag); + if (rider != null) { + setYRot(rider.getYRot()); + } + } + + /** + * The server moves the parachute; clients ease towards each position it sends over three + * ticks, as for the vehicles. Without this a slow descent steps from one update to the next. + */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + entityData.set(DATA_KIND, input.getIntOr("Kind", Kind.ITEM.ordinal())); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("Kind", kind().ordinal()); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/PartState.java new file mode 100644 index 00000000..54755d56 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java new file mode 100644 index 00000000..b80171b8 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 00000000..e5781817 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,106 @@ +package com.jnhprojects.mcheli.entity; + +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java new file mode 100644 index 00000000..e729c7f5 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java new file mode 100644 index 00000000..92e8e59e --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 00000000..dd7e29b2 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,339 @@ +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.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.chat.Component; +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.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

The original kept the player at the station and had the drone read its pilot from there. + * Here the pilot takes the drone's reserved seat for as long as they fly it, hidden and unhurt, + * so the drone uses the same controls, cameras, HUD and weapons as every other vehicle. Leaving + * the drone, or losing it, puts the pilot back beside the station. + */ +public class UavStationEntity extends Entity { + public static final int STATION = 1; + public static final int PORTABLE = 2; + /** Further out than this, measured flat, the link breaks and the drone is lost, as in the original. */ + public static final double RANGE = 123.0; + /** The launch offset goes up to this far from the station along each axis. */ + public static final int MAX_OFFSET = 50; + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_OPEN = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_OFFSET = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BLOCK_POS); + private static final EntityDataAccessor DATA_LINKED = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final SimpleContainer slot = new SimpleContainer(1); + private @Nullable UUID lastUav; + /** The lid of the portable controller, 0 shut to 1 open. */ + private float cover; + private float previousCover; + + public UavStationEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, (byte) STATION); + builder.define(DATA_OPEN, true); + builder.define(DATA_OFFSET, new BlockPos(12, 2, 12)); + builder.define(DATA_LINKED, false); + } + + public int kind() { + return entityData.get(DATA_KIND); + } + + public void setKind(int kind) { + entityData.set(DATA_KIND, (byte) (kind == PORTABLE ? PORTABLE : STATION)); + } + + public boolean isOpen() { + return entityData.get(DATA_OPEN); + } + + /** Where a launched drone appears, relative to the station. */ + public BlockPos offset() { + return entityData.get(DATA_OFFSET); + } + + public void setOffset(BlockPos offset) { + entityData.set(DATA_OFFSET, new BlockPos(Mth.clamp(offset.getX(), -MAX_OFFSET, MAX_OFFSET), + Mth.clamp(offset.getY(), -MAX_OFFSET, MAX_OFFSET), Mth.clamp(offset.getZ(), -MAX_OFFSET, MAX_OFFSET))); + } + + /** Whether a drone is flown from here right now; the screen lights up. */ + public boolean isLinked() { + return entityData.get(DATA_LINKED); + } + + public void setLinked(boolean linked) { + entityData.set(DATA_LINKED, linked); + } + + public SimpleContainer slot() { + return slot; + } + + public float cover(float partialTicks) { + return Mth.lerp(partialTicks, previousCover, cover); + } + + /** + * {@code initUavPostion}: the first launch offset is twelve blocks out on the diagonal the + * station faces, two up. + */ + public void initOffset() { + int quarter = (int) (Mth.positiveModulo(getYRot() + 45.0F, 360.0F) / 90.0F) % 4; + int x = quarter == 0 || quarter == 3 ? 12 : -12; + int z = quarter == 0 || quarter == 1 ? 12 : -12; + setOffset(new BlockPos(x, 2, z)); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + previousCover = cover; + cover = Mth.clamp(cover + (isOpen() ? 0.1F : -0.1F), 0.0F, 1.0F); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.03, 0.0); + move(MoverType.SELF, new Vec3(0.0, m.y, 0.0)); + setDeltaMovement(0.0, onGround() ? 0.0 : m.y * 0.96, 0.0); + + Entity user = getFirstPassenger(); + if (user instanceof ServerPlayer player && !slot.getItem(0).isEmpty()) { + launch(player, slot.getItem(0)); + } + } + + /** What the slot holds, if it is a vehicle at all. */ + public static @Nullable AircraftDefinition definitionOf(ItemStack stack) { + String name = AircraftItem.vehicleOf(stack); + return name != null ? MCHeli.content().any(name) : null; + } + + /** Whether this station can put up the vehicle in {@code stack}. */ + public boolean accepts(@Nullable AircraftDefinition def) { + return def != null && def.uav && (kind() != PORTABLE || def.smallUav); + } + + /** {@code handleItem}: puts up the drone in the slot and hands it to {@code player}. */ + private void launch(ServerPlayer player, ItemStack stack) { + AircraftDefinition def = definitionOf(stack); + if (!accepts(def) || !(level() instanceof ServerLevel server)) { + return; + } + if (ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return; + } + AircraftEntity uav = ModEntities.typeFor(def).create(server, EntitySpawnReason.TRIGGERED); + if (uav == null) { + return; + } + BlockPos o = offset(); + double y = Math.max(getY() + o.getY(), server.getMinY() + 2.0); + uav.setType(def); + uav.snapTo(getX() + o.getX(), y, getZ() + o.getZ(), getYRot() - 180.0F, 0.0F); + if (!server.noCollision(uav, uav.getBoundingBox().deflate(0.1))) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.blocked"), true); + slot.setItem(0, ItemStack.EMPTY); + Containers.dropItemStack(server, player.getX(), player.getY(), player.getZ(), stack); + return; + } + slot.setItem(0, ItemStack.EMPTY); + uav.setFuelLevel(def.targetDrone ? uav.maxFuel() : (int) (uav.maxFuel() * 0.05F)); + server.addFreshEntity(uav); + if (!def.targetDrone) { + player.closeContainer(); + control(player, uav); + } + } + + /** {@code controlLastAircraft}: takes the last drone flown from here again. */ + public boolean continueControl(ServerPlayer player) { + AircraftEntity uav = lastUav(); + if (uav == null || getFirstPassenger() != player || !uav.canTakeRemotePilot()) { + return false; + } + player.closeContainer(); + return control(player, uav); + } + + public @Nullable AircraftEntity lastUav() { + if (lastUav == null || !(level() instanceof ServerLevel server)) { + return null; + } + Entity e = server.getEntity(lastUav); + return e instanceof AircraftEntity uav && uav.isAlive() && !uav.isDestroyed() && inRange(uav) ? uav : null; + } + + public boolean inRange(Entity uav) { + double dx = uav.getX() - getX(); + double dz = uav.getZ() - getZ(); + return dx * dx + dz * dz <= RANGE * RANGE; + } + + private boolean control(ServerPlayer player, AircraftEntity uav) { + player.stopRiding(); + if (!uav.takeRemotePilot(player, this)) { + return false; + } + lastUav = uav.getUUID(); + setLinked(true); + player.setYRot(uav.getYRot()); + return true; + } + + /** Where a pilot comes back to when the drone is left or lost: the seat in front of the station. */ + public Vec3 returnPosition() { + float yaw = getYRot() * Mth.DEG_TO_RAD; + return new Vec3(getX() - Mth.sin(yaw) * 0.9, getY(), getZ() + Mth.cos(yaw) * 0.9); + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + if (isVehicle()) { + return InteractionResult.PASS; + } + if (kind() == PORTABLE && player.isSecondaryUseActive()) { + if (!level().isClientSide()) { + entityData.set(DATA_OPEN, !isOpen()); + } + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive() || kind() == PORTABLE && !isOpen()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer server) { + player.setYRot(getYRot() - 180.0F); + if (player.startRiding(this)) { + server.openMenu(new SimpleMenuProvider((id, inventory, p) -> new UavStationMenu(id, inventory, this), + Component.translatable(kind() == PORTABLE ? "item.mcheli.uav_station2" : "item.mcheli.uav_station"))); + } + } + return InteractionResult.SUCCESS; + } + + /** The original sat the player down in front of the console, lower for the portable one. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + double y = kind() == PORTABLE ? -0.6 : 0.35; + return new Vec3(0.0, y, 0.9).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || damage <= 0.0F) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + AircraftEntity uav = lastUav(); + if (uav != null) { + uav.releaseRemotePilot(); + } + Containers.dropContents(level, blockPosition(), slot); + if (!creative) { + spawnAtLocation(level, item()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), source.getEntity() instanceof Player ? "hit" : "helidmg", 1.0F, 1.0F); + discard(); + return true; + } + + private Item item() { + return kind() == PORTABLE ? ModItems.UAV_CONTROLLER.get() : ModItems.UAV_STATION.get(); + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return true; + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(item()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + setKind(input.getIntOr("Kind", STATION)); + entityData.set(DATA_OPEN, input.getBooleanOr("Open", true)); + setOffset(new BlockPos(input.getIntOr("UavX", 12), input.getIntOr("UavY", 2), input.getIntOr("UavZ", 12))); + lastUav = input.read("LastUav", UUIDUtil.CODEC).orElse(null); + slot.setItem(0, input.read("Item", ItemStack.CODEC).orElse(ItemStack.EMPTY)); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("Kind", kind()); + output.putBoolean("Open", isOpen()); + BlockPos o = offset(); + output.putInt("UavX", o.getX()); + output.putInt("UavY", o.getY()); + output.putInt("UavZ", o.getZ()); + if (lastUav != null) { + output.store("LastUav", UUIDUtil.CODEC, lastUav); + } + if (!slot.getItem(0).isEmpty()) { + output.store("Item", ItemStack.CODEC, slot.getItem(0)); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java new file mode 100644 index 00000000..1fdc978e --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.supply.SupplyRules; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** + * What a vehicle carries besides its crew, {@code MCH_AircraftInventory} and the container half of + * {@code W_EntityContainer}: the equipment slots of the vehicle screen (three fuel cans and a + * parachute per ejection seat) and the cargo chest of vehicles with an {@code InventorySize}. + * + *

The cargo chest always has room for the largest size, 54 slots, and the screen shows as many + * rows as the definition asks for. Items in rows a content reload took away are kept rather than + * lost, and still drop when the vehicle is destroyed. + */ +public final class VehicleInventory { + public static final int FUEL_SLOTS = 3; + public static final int PARACHUTE_SLOTS = 2; + public static final int EQUIPMENT_SLOTS = FUEL_SLOTS + PARACHUTE_SLOTS; + public static final int CARGO_SLOTS = 54; + /** How far from the vehicle's centre a player outside may keep a screen open, past its width. */ + private static final double REACH = 12.0; + + private final AircraftEntity vehicle; + private final SimpleContainer equipment = new GuardedContainer(EQUIPMENT_SLOTS); + private final SimpleContainer cargo = new GuardedContainer(CARGO_SLOTS); + + VehicleInventory(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + public SimpleContainer equipment() { + return equipment; + } + + public SimpleContainer cargo() { + return cargo; + } + + /** {@code isUseableByPlayer}: the crew, or someone standing next to the vehicle. */ + public boolean stillValid(Player player) { + if (vehicle.isRemoved() || vehicle.definition() == null) { + return false; + } + if (player.getVehicle() == vehicle) { + return true; + } + double reach = REACH + vehicle.definition().bodyWidth / 2.0; + return player.distanceToSqr(vehicle) <= reach * reach; + } + + // Fuel + + /** + * {@code updateFuel}: every half second, while the vehicle stands on something, each fuel can + * in a fuel slot pours up to 100 units into the tank. Server only. + * + * @return whether any fuel went in + */ + boolean pourFuelCans(int ticks) { + if (ticks % SupplyRules.FUEL_SLOT_INTERVAL != 0 || !vehicle.canSupply()) { + return false; + } + boolean poured = false; + for (int slot = 0; slot < FUEL_SLOTS; slot++) { + ItemStack can = equipment.getItem(slot); + if (!(can.getItem() instanceof FuelItem)) { + continue; + } + int amount = SupplyRules.fuelFromCan(vehicle.maxFuel() - vehicle.fuel(), FuelItem.contents(can)); + if (amount > 0) { + vehicle.refuel(amount); + can.setDamageValue(can.getDamageValue() + amount); + poured = true; + } + } + if (poured) { + equipment.setChanged(); + } + return poured; + } + + // Parachutes + + /** Parachute slots the vehicle screen offers: one per ejection seat. */ + public static int parachuteSlots(AircraftDefinition def) { + return Math.min(def.ejectionSeats(), PARACHUTE_SLOTS); + } + + /** + * {@code consumeParachute}: takes one parachute out of the parachute slots for a crew member + * who bails out. + * + * @return false when there is none left + */ + boolean takeParachute() { + for (int slot = FUEL_SLOTS; slot < EQUIPMENT_SLOTS; slot++) { + if (equipment.getItem(slot).getItem() instanceof ParachuteItem) { + equipment.removeItem(slot, 1); + return true; + } + } + return false; + } + + // Saving and dropping + + void save(ValueOutput output) { + ContainerHelper.saveAllItems(output.child("Equipment"), equipment.getItems(), false); + ContainerHelper.saveAllItems(output.child("Cargo"), cargo.getItems(), false); + } + + void load(ValueInput input) { + equipment.clearContent(); + cargo.clearContent(); + input.child("Equipment").ifPresent(in -> ContainerHelper.loadAllItems(in, equipment.getItems())); + input.child("Cargo").ifPresent(in -> ContainerHelper.loadAllItems(in, cargo.getItems())); + } + + /** {@code setDead}: everything aboard falls out where the vehicle was. Server only. */ + void dropAll() { + Containers.dropContents(vehicle.level(), vehicle, equipment); + Containers.dropContents(vehicle.level(), vehicle, cargo); + } + + /** A container that only stays open for players {@link #stillValid(Player)} allows. */ + private final class GuardedContainer extends SimpleContainer { + GuardedContainer(int size) { + super(size); + } + + @Override + public boolean stillValid(Player player) { + return VehicleInventory.this.stillValid(player); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java new file mode 100644 index 00000000..46ed88a8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.ParticleSplash; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * The spray, dust and exhaust a vehicle throws up, all client side: + *

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java new file mode 100644 index 00000000..e72e952e --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java @@ -0,0 +1,160 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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(), EntitySpawnReason.TRIGGERED); + 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 hurtServer(ServerLevel level, 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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java new file mode 100644 index 00000000..a9d41520 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java new file mode 100644 index 00000000..e5f46c96 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java new file mode 100644 index 00000000..d7031ecf --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java new file mode 100644 index 00000000..c758d75b --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java new file mode 100644 index 00000000..b2b418ac --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java new file mode 100644 index 00000000..69a2f674 --- /dev/null +++ b/minecraft-1.21.11/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.getMinY() || pos.getY() >= server.getMaxY()) { + 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.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java new file mode 100644 index 00000000..a564a1fc --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java @@ -0,0 +1,161 @@ +package com.jnhprojects.mcheli.entity.projectile; + +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.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.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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) { + snapTo(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 hurtServer(ServerLevel level, 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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java new file mode 100644 index 00000000..eb723310 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java @@ -0,0 +1,139 @@ +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.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; + +/** + * 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(rgb, 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(), EntitySpawnReason.TRIGGERED); + 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().getMaxY(); 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public String defaultModel() { + return "rocket"; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java new file mode 100644 index 00000000..30f171c5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -0,0 +1,790 @@ +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.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.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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); + /** Who fired, for clients: their rounds must not stop on their own vehicle. */ + private static final EntityDataAccessor DATA_SHOOTER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_USER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + + /** 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; + /** Client only: the round hit something here and waits, hidden, for the server to remove it. */ + private boolean spent; + + 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); + builder.define(DATA_SHOOTER, 0); + builder.define(DATA_USER, 0); + } + + /** + * 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) { + snapTo(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; + setOwners(shooter, 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; + setOwners(parent.shooter, parent.user); + } + + public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + entityData.set(DATA_SHOOTER, shooter != null ? shooter.getId() : 0); + entityData.set(DATA_USER, user != null ? user.getId() : 0); + } + + 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 (spent) { + return; + } + 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().getMaxY() && !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(), EntitySpawnReason.TRIGGERED); + 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; + } + + /** Clients only learn who fired through synched data; look them up once it has arrived. */ + private void resolveOwners() { + if (shooter == null && entityData.get(DATA_SHOOTER) > 0) { + shooter = level().getEntity(entityData.get(DATA_SHOOTER)); + } + if (user == null && entityData.get(DATA_USER) > 0) { + user = level().getEntity(entityData.get(DATA_USER)); + } + } + + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ + public boolean canHit(Entity entity) { + if (level().isClientSide()) { + resolveOwners(); + } + 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); + } + if (piercing <= 0) { + // The server decides what the hit does; until its removal arrives the round stays + // where it struck instead of flying on through the hull. + spent = true; + setPos(hit.location()); + setDeltaMovement(Vec3.ZERO); + } + 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 hurtServer(ServerLevel level, 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; + } + + /** Client only: whether the round already hit something and should no longer be drawn. */ + public boolean isSpent() { + return spent; + } + + @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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putString("WeaponName", weaponName()); + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java new file mode 100644 index 00000000..c39fa6fe --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 00000000..479ead5a --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,200 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +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.util.ARGB; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = ARGB.redFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float g = ARGB.greenFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float b = ARGB.blueFloat(color) * 0.9F + random.nextFloat() * 0.1F; + if (ARGB.red(color) == ARGB.green(color)) { + g = r; + } + if (ARGB.red(color) == ARGB.blue(color)) { + b = r; + } + if (ARGB.green(color) == ARGB.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(ARGB.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java new file mode 100644 index 00000000..a89f7c01 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java new file mode 100644 index 00000000..a06fef1a --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 00000000..cb0312c4 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,146 @@ +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.content.pack.PackManifest; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; +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.function.Consumer; + +/** + * 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; 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)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + 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); + } + + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, Consumer out, TooltipFlag flag) { + super.appendHoverText(stack, context, display, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.accept((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { + return InteractionResult.PASS; + } + + 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 InteractionResult.FAIL; + } + if (!level.isClientSide() && ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return InteractionResult.FAIL; + } + + AircraftEntity vehicle = ModEntities.typeFor(def).create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (vehicle == null) { + return InteractionResult.FAIL; + } + Vec3 at = blockHit.getLocation(); + vehicle.setType(def); + vehicle.snapTo(at.x, at.y, at.z, player.getYRot(), 0.0F); + if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { + return InteractionResult.FAIL; + } + + 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 InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 00000000..0f89341b --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.removed"), true); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.released"), true); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance)), true); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.no_vehicle"), true); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.displayClientMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 00000000..efa8f295 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * The fuel can, {@code MCH_ItemFuel}. It holds 600 units; the durability bar shows how much is + * left. Using it refills it from the coal and charcoal in the inventory, as in the original. + * + *

A can in a fuel slot of the vehicle screen empties into the tank while the vehicle stands on + * something, as in the original. Not in the original: right-clicking a vehicle with the can pours + * up to 100 units at once, which saves opening the screen. + */ +public class FuelItem extends Item { + /** Units a full can holds, the original's maximum damage. */ + public static final int CAPACITY = 600; + /** Units poured into a vehicle per click: what the original moved per fuel slot per update. */ + private static final int POUR = 100; + private static final int PER_COAL = 100; + private static final int PER_CHARCOAL = 75; + + public FuelItem(Properties properties) { + super(properties.durability(CAPACITY)); + } + + public static int contents(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + /** Fills a vehicle's tank from the can. Server only. */ + public static InteractionResult pour(ItemStack stack, AircraftEntity vehicle, Player player) { + int added = vehicle.refuel(player.getAbilities().instabuild ? POUR : Math.min(contents(stack), POUR)); + if (added <= 0) { + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) { + stack.setDamageValue(stack.getDamageValue() + added); + } + vehicle.level().playSound(null, vehicle.getX(), vehicle.getY(), vehicle.getZ(), SoundEvents.BUCKET_EMPTY, SoundSource.NEUTRAL, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } + + /** Charcoal first, then coal, each one worth part of the can, until it is full. */ + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + if (!stack.isDamaged() || player.getAbilities().instabuild) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + refill(stack, player.getInventory(), Items.CHARCOAL, PER_CHARCOAL); + refill(stack, player.getInventory(), Items.COAL, PER_COAL); + } + return InteractionResult.SUCCESS; + } + + private static void refill(ItemStack can, Inventory inventory, Item coal, int worth) { + for (int slot = 0; slot < inventory.getContainerSize() && can.isDamaged(); slot++) { + ItemStack item = inventory.getItem(slot); + while (item.is(coal) && !item.isEmpty() && can.isDamaged()) { + can.setDamageValue(Math.max(can.getDamageValue() - worth, 0)); + item.shrink(1); + } + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 00000000..a83ff5bd --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 00000000..0a286838 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java @@ -0,0 +1,61 @@ +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.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +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, ServerLevel level, Entity owner, @Nullable EquipmentSlot slot) { + if (!(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.11/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 00000000..2af7b79b --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.Level; + +/** The parachute, {@code MCH_ItemParachute}: used in mid-air, it opens above the player. */ +public class ParachuteItem extends Item { + public ParachuteItem(Properties properties) { + super(properties.stacksTo(1)); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (player.isPassenger() || player.onGround()) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + ParachuteEntity.open(level, player, ParachuteEntity.Kind.ITEM); + player.getItemInHand(hand).consume(1, player); + } + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 00000000..34c524d5 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.snapTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 00000000..6a4fb996 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.SPYGLASS; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 00000000..df8f33f8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,123 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResult.FAIL; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } + + @Override + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return false; + } + int drawn = Math.max(getUseDuration(stack, entity) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + return true; + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 00000000..7e3bc1c8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 00000000..f6c65c79 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java @@ -0,0 +1,67 @@ +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.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(AircraftItem.stackOf(def)) + : SlotDisplay.Empty.INSTANCE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 00000000..8ad8f03f --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * The wrench, {@code MCH_ItemWrench}. Right-clicking a damaged vehicle repairs 10 hit points, once + * a second, at one point of the wrench's durability. Sneaking switches the vehicle to its next + * paint scheme. + * + *

The original repaired while the use key was held; a click per repair works the same with + * today's item use, which stops as soon as the cursor leaves the hull. + */ +public class WrenchItem extends Item { + /** An iron tool's durability, the original's tool material. */ + private static final int DURABILITY = 250; + private static final int REPAIR = 10; + private static final int COOLDOWN = 20; + + public WrenchItem(Properties properties) { + super(properties.durability(DURABILITY)); + } + + /** Server only. */ + public static InteractionResult useOn(ItemStack stack, AircraftEntity vehicle, Player player, InteractionHand hand) { + if (player.isSecondaryUseActive()) { + return vehicle.nextTexture() ? InteractionResult.SUCCESS : InteractionResult.FAIL; + } + if (player.getCooldowns().isOnCooldown(stack) || !vehicle.repair(REPAIR)) { + return InteractionResult.FAIL; + } + player.getCooldowns().addCooldown(stack, COOLDOWN); + stack.hurtAndBreak(1, player, hand); + ModSounds.playAt(vehicle, "wrench", 1.0F, 0.9F + vehicle.getRandom().nextFloat() * 0.2F); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 00000000..0c5c7885 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,148 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes in the player's recipe book and asks for one by its + * display id; the server finds the recipe, takes the ingredients from the inventory and puts the + * result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addStandardInventorySlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, RecipeDisplayId id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeManager.ServerDisplayInfo info = player.level().getServer().getRecipeManager().getRecipeFromDisplay(id); + if (info == null) { + return; + } + RecipeHolder holder = info.parent(); + if (!holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + List ingredients = recipe.placementInfo().ingredients(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.assemble(CraftingInput.EMPTY, player.registryAccess())); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 00000000..4c5f7bd8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addStandardInventorySlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java new file mode 100644 index 00000000..b2dba1e4 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java @@ -0,0 +1,217 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGuiContainer}: fuel slots, a parachute slot per ejection + * seat, reloading the weapons from the crew member's own items and the way into the cargo chest. + * + *

Only crew open it, so the client finds the vehicle as the one its player rides. The weapon + * rounds and which weapons can be reloaded right now come from the server through data slots, + * since a client only hears about the ammunition of the seat it sits in. + */ +public class VehicleMenu extends AbstractContainerMenu { + /** Weapons the screen can show; more than any vehicle in the original has. */ + public static final int MAX_WEAPONS = 32; + /** Button ids from zero up reload that weapon; this one opens the cargo chest. */ + public static final int BUTTON_CARGO = 1000; + + public static final int FUEL_X = 8; + public static final int FUEL_Y = 30; + public static final int PARACHUTE_X = 8; + public static final int PARACHUTE_Y = 66; + public static final int INVENTORY_Y = 102; + + private static final int DATA_RELOADABLE = MAX_WEAPONS; + private static final int DATA_SIZE = MAX_WEAPONS + 2; + + private final @Nullable AircraftEntity vehicle; + private final Container equipment; + private final ContainerData data; + private final int parachuteSlots; + private boolean supplied; + + /** The client side, from the open screen packet. */ + public VehicleMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof AircraftEntity v ? v : null, + new SimpleContainer(VehicleInventory.EQUIPMENT_SLOTS), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public VehicleMenu(int containerId, Inventory inventory, AircraftEntity vehicle) { + this(containerId, inventory, vehicle, vehicle.inventory().equipment(), new ServerData(vehicle, inventory.player)); + } + + private VehicleMenu(int containerId, Inventory inventory, @Nullable AircraftEntity vehicle, Container equipment, ContainerData data) { + super(ModMenus.VEHICLE.get(), containerId); + this.vehicle = vehicle; + this.equipment = equipment; + this.data = data; + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + parachuteSlots = def != null ? VehicleInventory.parachuteSlots(def) : 0; + checkContainerSize(equipment, VehicleInventory.EQUIPMENT_SLOTS); + + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + addSlot(new Slot(equipment, i, FUEL_X + i * 18, FUEL_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof FuelItem; + } + }); + } + for (int i = 0; i < VehicleInventory.PARACHUTE_SLOTS; i++) { + boolean active = i < parachuteSlots; + addSlot(new Slot(equipment, VehicleInventory.FUEL_SLOTS + i, PARACHUTE_X + i * 18, PARACHUTE_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return active && stack.getItem() instanceof ParachuteItem; + } + + @Override + public boolean isActive() { + return active; + } + }); + } + addStandardInventorySlots(inventory, 8, INVENTORY_Y); + addDataSlots(data); + } + + public @Nullable AircraftEntity vehicle() { + return vehicle; + } + + /** Parachute slots in use: one per ejection seat. */ + public int parachuteSlots() { + return parachuteSlots; + } + + /** Magazine and reserve of a weapon together, as the server last reported them. */ + public int rounds(int weapon) { + return weapon >= 0 && weapon < MAX_WEAPONS ? data.get(weapon) : 0; + } + + /** Whether the server would reload this weapon now: the vehicle is down and the items are there. */ + public boolean canReload(int weapon) { + if (weapon < 0 || weapon >= MAX_WEAPONS) { + return false; + } + int bits = (data.get(DATA_RELOADABLE) & 0xFFFF) | (data.get(DATA_RELOADABLE + 1) & 0xFFFF) << 16; + return (bits & 1 << weapon) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (vehicle == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CARGO) { + return vehicle.openCargo(server); + } + if (id >= 0 && id < MAX_WEAPONS && Resupply.supply(server, vehicle, id)) { + supplied = true; + return true; + } + return false; + } + + @Override + public boolean stillValid(Player player) { + return vehicle != null && equipment.stillValid(player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + int equipmentEnd = VehicleInventory.EQUIPMENT_SLOTS; + if (index < equipmentEnd) { + if (!moveItemStackTo(stack, equipmentEnd, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof FuelItem) { + if (!moveItemStackTo(stack, 0, VehicleInventory.FUEL_SLOTS, false)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof ParachuteItem && parachuteSlots > 0) { + int first = VehicleInventory.FUEL_SLOTS; + if (!moveItemStackTo(stack, first, first + parachuteSlots, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** As in the original, closing the screen after a resupply loads every magazine from its reserve. */ + @Override + public void removed(Player player) { + super.removed(player); + if (supplied && vehicle != null && vehicle.weapons() != null && !player.level().isClientSide()) { + vehicle.weapons().reloadAll(); + } + } + + /** Rounds per weapon and the reload bits, worked out on the server whenever the menu syncs. */ + private record ServerData(AircraftEntity vehicle, Player player) implements ContainerData { + @Override + public int get(int index) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons == null) { + return 0; + } + if (index < MAX_WEAPONS) { + WeaponSet set = weapons.set(index); + // Data slots travel as shorts. + return set != null ? Math.min(set.ammo() + set.restAmmo(), Short.MAX_VALUE) : 0; + } + int bits = 0; + if (player instanceof ServerPlayer server) { + for (int i = 0; i < Math.min(weapons.sets().size(), MAX_WEAPONS); i++) { + if (Resupply.canSupply(server, vehicle, i)) { + bits |= 1 << i; + } + } + } + return index == DATA_RELOADABLE ? bits & 0xFFFF : bits >>> 16; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java new file mode 100644 index 00000000..0f7ccf72 --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java new file mode 100644 index 00000000..1b565e21 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -0,0 +1,164 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +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, + /** Open the vehicle screen: fuel and parachute slots, reloading, the way to the cargo chest. */ + VEHICLE_SCREEN, + /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ + GUNNER_MODE, + /** Turn night vision on for the sender, in vehicles with {@code EnableNightVision}. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT + } + + 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 VEHICLE_SCREEN -> vehicle.openScreen(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 00000000..46294aa3 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +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.item.crafting.display.RecipeDisplayId; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe the player's recipe book knows by this display id. + */ +public record DraftingTablePacket(RecipeDisplayId recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + RecipeDisplayId.STREAM_CODEC.encode(buf, msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(RecipeDisplayId.STREAM_CODEC.decode(buf)); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 00000000..76090f2e --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +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 request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket 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 equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java new file mode 100644 index 00000000..279b1511 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java new file mode 100644 index 00000000..0056dcc0 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 00000000..d3f204c7 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,119 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.level(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED) + : ModEntities.AA_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java new file mode 100644 index 00000000..97d2cbf4 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 00000000..1de0bfc3 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +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 client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java new file mode 100644 index 00000000..26065705 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,90 @@ +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 = 11; + + /** 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<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), + new Serverbound<>(TestFlightPacket.TYPE, TestFlightPacket.STREAM_CODEC, TestFlightPacket::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), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + 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), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 00000000..89c1644d --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 00000000..ad7f0c85 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 00000000..50f10f74 --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..b25eba0a --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

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

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.nonCriticalIoPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 00000000..b3e53269 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,158 @@ +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); + } + PackSubmissions.onLogin(player); + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); + } + + /** The packs the server uses now, as the client should load them. */ + 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.nameAndId()) + || Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(player.createCommandSourceStack()); + } + + 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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java new file mode 100644 index 00000000..6426f060 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 00000000..1ee72286 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +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.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 00000000..14b2db42 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +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 button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java new file mode 100644 index 00000000..db12a390 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java @@ -0,0 +1,34 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.workshop.TestFlight; +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; + +/** The workshop's Test flight button: fly the vehicle just saved. The server checks who is asking. */ +public record TestFlightPacket(String vehicle) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("test_flight"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TestFlightPacket::encode, TestFlightPacket::decode); + private static final int MAX_NAME = 64; + + public static void encode(TestFlightPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.vehicle, MAX_NAME); + } + + public static TestFlightPacket decode(FriendlyByteBuf buf) { + return new TestFlightPacket(buf.readUtf(MAX_NAME)); + } + + public static void handle(TestFlightPacket msg, ServerPlayer sender) { + if (sender != null) { + TestFlight.start(sender, msg.vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java new file mode 100644 index 00000000..292f643e --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 00000000..63a3577f --- /dev/null +++ b/minecraft-1.21.11/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( + ExtraCodecs.RGB_COLOR_CODEC.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.11/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 00000000..29a0b0ef --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +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 puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + ExtraCodecs.RGB_COLOR_CODEC.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 00000000..bbff8489 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Platform.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * 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); + + /** A block entity type; vanilla's constructor is private on 1.21.11. */ + BlockEntityType blockEntityType(BiFunction factory, Block block); + + final class Holder { + private static volatile Platform instance; + + private Holder() { + } + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 00000000..3d1b9085 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 00000000..030a07dd --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 00000000..00d40aed --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,21 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +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.world.level.block.entity.BlockEntityType; + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> Platform.get().blockEntityType(DraftingTableBlockEntity::new, ModBlocks.DRAFTING_TABLE.get())); + + private ModBlockEntities() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 00000000..19995a92 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of().setId(key) + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java new file mode 100644 index 00000000..870aa8b8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -0,0 +1,72 @@ +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.item.ThrowableItem; +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, and one for + * the equipment and throwables. Those lists are 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)); + public static final Registered EQUIPMENT = Registration.register(Registries.CREATIVE_MODE_TAB, "equipment", + key -> Platform.get().creativeTab(key(VEHICLES)) + .title(Component.translatable("itemGroup.mcheli.equipment")) + .icon(() -> new ItemStack(ModItems.WRENCH.get())) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) + .build()); + + 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(), EQUIPMENT.get()); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 00000000..377a5783 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,34 @@ +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()); + + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + 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-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 00000000..b6686ccd --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,170 @@ +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.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.ThrowableEntity; +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)); + + public static final Registered> PARACHUTE = Registration.register(Registries.ENTITY_TYPE, "parachute", key -> EntityType.Builder + .of(ParachuteEntity::new, MobCategory.MISC) + .sized(1.5F, 0.6F) + .clientTrackingRange(10) + .updateInterval(1) + .build(key)); + + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key)); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key)); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key)); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key)); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .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-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 00000000..e7415dfd --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,102 @@ +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.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; +import com.jnhprojects.mcheli.item.WrenchItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; + +import java.util.List; +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. The + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. + */ +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"); + + public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties().setId(key))); + public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties().setId(key))); + public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties().setId(key))); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties().setId(key))); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties().setId(key))); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties().setId(key))); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties().setId(key).useBlockDescriptionPrefix())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties().setId(key))); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); + + 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) || EQUIPMENT.stream().anyMatch(e -> e.id().getPath().equals(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-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java new file mode 100644 index 00000000..0dac0052 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.MenuType; + +/** Our screens with slots. The cargo chest uses vanilla's chest menus and needs no entry here. */ +public final class ModMenus { + public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", + key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); + + private ModMenus() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 00000000..4ed53acd --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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; + } + }); + + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 00000000..4d869373 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 00000000..88123ade --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,116 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + /** The colours a team can have. */ + public static final List COLORS = Arrays.stream(ChatFormatting.values()).filter(ChatFormatting::isColor).toList(); + + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER); + } + + /** Switches the {@code pvp} game rule in every dimension and says which way it went. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !player.level().getGameRules().get(GameRules.PVP); + for (ServerLevel level : server.getAllLevels()) { + level.getGameRules().set(GameRules.PVP, pvp, server); + } + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.displayClientMessage(Component.translatable("message.mcheli.team.no_teams"), true); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.displayClientMessage(Component.translatable("message.mcheli.team.bad_name", trimmed), true); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(COLORS.get(Math.floorMod(color, COLORS.size()))); + team.setAllowFriendlyFire(friendlyFire); + player.displayClientMessage(Component.translatable("message.mcheli.team.created", trimmed), true); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + target.teleport(target.findRespawnPositionAndUseSpawnBlock(false, TeleportTransition.DO_NOTHING)); + } + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.displayClientMessage(Component.translatable("message.mcheli.team.destroyed", count), true); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 00000000..e78278a8 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,125 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + 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, EntitySpawnReason.TRIGGERED); + if (a10 == null) { + return; + } + a10.setPos(x, y, z); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java new file mode 100644 index 00000000..204fe93f --- /dev/null +++ b/minecraft-1.21.11/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.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 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 level)) { + return false; + } + if (target instanceof LivingEntity living) { + living.invulnerableTime = 0; + } + 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(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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java new file mode 100644 index 00000000..4f4b0dc8 --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java new file mode 100644 index 00000000..f7fe3e89 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -0,0 +1,217 @@ +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.EntitySpawnReason; +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.ServerExplosion; +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, EntitySpawnReason.TRIGGERED); + 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 = ServerExplosion.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.11/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java new file mode 100644 index 00000000..12ddc658 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java @@ -0,0 +1,195 @@ +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.entity.EntitySpawnReason; +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(), EntitySpawnReason.TRIGGERED); + 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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java new file mode 100644 index 00000000..b8142e92 --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java new file mode 100644 index 00000000..f763f73f --- /dev/null +++ b/minecraft-1.21.11/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.Identifier; +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; + }; + Identifier key = Identifier.tryParse(id.contains(":") ? id : "minecraft:" + id); + if (key == null) { + return null; + } + Item item = BuiltInRegistries.ITEM.getValue(key); + return item == Items.AIR ? null : item; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java new file mode 100644 index 00000000..b4007926 --- /dev/null +++ b/minecraft-1.21.11/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.Identifier; +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, Identifier.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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java new file mode 100644 index 00000000..533f5fba --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -0,0 +1,82 @@ +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 a crew member's own items, the {@code supplyAmmo} path of the + * original's vehicle screen. One supply costs the weapon's {@code Item} lines and adds + * {@code SuppliedNum} rounds; it only works while the vehicle stands on something. + */ +public final class Resupply { + private Resupply() { + } + + /** {@code canPlayerSupplyAmmo}: whether {@link #supply} would do anything right now. */ + public static boolean canSupply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.set(weapon) : null; + if (set == null || vehicle.isDestroyed() || player.getVehicle() != vehicle || !vehicle.canSupply()) { + return false; + } + if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { + return false; + } + return player.getAbilities().instabuild || canPay(player.getInventory(), set.definition()); + } + + /** Pays for and adds one supply of {@code weapon}. Server only. */ + public static boolean supply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + if (!canSupply(player, vehicle, weapon)) { + return false; + } + WeaponSet set = vehicle.weapons().set(weapon); + if (!player.getAbilities().instabuild) { + pay(player.getInventory(), set.definition()); + } + 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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java new file mode 100644 index 00000000..3f867744 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -0,0 +1,418 @@ +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.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.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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, EntitySpawnReason.TRIGGERED); + 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, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; + } + + private static void sound(WeaponSet set, Entity at, String name) { + if (set.soundAllowed) { + WeaponDefinition w = set.definition(); + float rnd = w.soundPitchRandom; + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + } + } + + // 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) { + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? soundName(set) : "ng"); + return true; + } + List spotted = new ArrayList<>(); + 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; + } + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); + } + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "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; + set.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); + set.casUser = shot.user; + set.casShooter = shot.shooter; + set.casStartTick = CasStrike.FIRST_PASS; + 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; + } + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); + set.casStartTick = set.ticksSinceShot(0) + 45; + set.casCount++; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java new file mode 100644 index 00000000..6a9f53cf --- /dev/null +++ b/minecraft-1.21.11/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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 00000000..b9331154 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +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 java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.level(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.level(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} diff --git a/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java new file mode 100644 index 00000000..af94a9dc --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -0,0 +1,598 @@ +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()) { + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); + } + } + 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(rgb, 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(rgb, 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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java new file mode 100644 index 00000000..3f539030 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -0,0 +1,505 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +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.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; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; + + /** Aim relative to the hull, following the crew member who operates the weapon. */ + public float rotationYaw; + 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); + } + } + + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + + // 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/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java new file mode 100644 index 00000000..4eea5656 --- /dev/null +++ b/minecraft-1.21.11/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; + +/** + * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server + * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. + */ +public final class TestFlight { + /** How far up to look for room when the vehicle does not fit where the player stands. */ + private static final int MAX_RISE = 48; + + private TestFlight() { + } + + public static void start(ServerPlayer player, String vehicle) { + MinecraftServer server = player.level().getServer(); + if (!server.isSingleplayerOwner(player.nameAndId())) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_owner_only"), true); + return; + } + if (player.isPassenger()) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_riding"), true); + return; + } + ContentReload.reload(server); + AircraftDefinition def = MCHeli.content().any(vehicle); + if (def == null) { + player.sendSystemMessage(Component.translatable("message.mcheli.workshop.test_flight_no_vehicle", vehicle)); + MCHeli.packs().reportProblems(player::sendSystemMessage); + return; + } + ServerLevel level = player.level(); + AircraftEntity aircraft = ModEntities.typeFor(def).create(level, EntitySpawnReason.COMMAND); + if (aircraft == null) { + return; + } + aircraft.setType(def); + Vec3 start = player.position(); + for (int rise = 0; rise <= MAX_RISE; rise++) { + aircraft.snapTo(start.x, start.y + rise, start.z, player.getYRot(), 0.0F); + if (level.noCollision(aircraft, aircraft.getBoundingBox())) { + break; + } + } + aircraft.startTestFlight(start); + level.addFreshEntity(aircraft); + player.startRiding(aircraft, true, true); + } +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 00000000..82992af9 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/items/drafting_table.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/drafting_table.json new file mode 100644 index 00000000..a3afc683 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/drafting_table.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "mcheli:block/drafting_table" + } +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/items/helicopter.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/helicopter.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/helicopter.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/items/plane.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/plane.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/plane.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/items/tank.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/tank.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/items/vehicle.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/vehicle.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/items/vehicle.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 00000000..8dfa5bcc --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 00000000..296e5397 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 00000000..977381be --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/post_effect/thermal.json b/minecraft-1.21.11/src/main/resources/assets/mcheli/post_effect/thermal.json new file mode 100644 index 00000000..593e905d --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/post_effect/thermal.json @@ -0,0 +1,38 @@ +{ + "targets": { + "swap": {} + }, + "passes": [ + { + "vertex_shader": "minecraft:core/screenquad", + "fragment_shader": "mcheli:post/thermal", + "inputs": [ + { + "sampler_name": "In", + "target": "minecraft:main" + } + ], + "output": "swap" + }, + { + "vertex_shader": "minecraft:core/screenquad", + "fragment_shader": "minecraft:post/blit", + "inputs": [ + { + "sampler_name": "In", + "target": "swap" + } + ], + "uniforms": { + "BlitConfig": [ + { + "name": "ColorModulate", + "type": "vec4", + "value": [ 1.0, 1.0, 1.0, 1.0 ] + } + ] + }, + "output": "minecraft:main" + } + ] +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/shaders/post/thermal.fsh b/minecraft-1.21.11/src/main/resources/assets/mcheli/shaders/post/thermal.fsh new file mode 100644 index 00000000..24f82a83 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/assets/mcheli/shaders/post/thermal.fsh @@ -0,0 +1,37 @@ +#version 330 + +// Thermal view, white hot. A post shader only sees colours, so heat is faked: bright and warm +// colours (fire, lava, skin, lamps) read hot, the sky and water cold, and outlines are drawn +// where the brightness changes sharply, like the original's "pencil" shader. + +uniform sampler2D InSampler; + +in vec2 texCoord; + +layout(std140) uniform SamplerInfo { + vec2 OutSize; + vec2 InSize; +}; + +out vec4 fragColor; + +const vec3 Gray = vec3(0.3, 0.59, 0.11); + +float heat(vec3 c) { + float luma = dot(c, Gray); + float warmth = clamp(c.r - c.b, 0.0, 1.0); + return clamp(luma * 0.7 + warmth * 0.6, 0.0, 1.0); +} + +void main() { + vec2 oneTexel = 1.0 / InSize; + float c = heat(texture(InSampler, texCoord).rgb); + float l = heat(texture(InSampler, texCoord - vec2(oneTexel.x, 0.0)).rgb); + float r = heat(texture(InSampler, texCoord + vec2(oneTexel.x, 0.0)).rgb); + float u = heat(texture(InSampler, texCoord - vec2(0.0, oneTexel.y)).rgb); + float d = heat(texture(InSampler, texCoord + vec2(0.0, oneTexel.y)).rgb); + float edge = clamp(abs(r - l) + abs(d - u), 0.0, 1.0); + + float value = smoothstep(0.08, 0.85, c) + edge * 0.6; + fragColor = vec4(vec3(clamp(value, 0.0, 1.0)), 1.0); +} diff --git a/minecraft-1.21.11/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-1.21.11/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 00000000..339b9729 Binary files /dev/null and b/minecraft-1.21.11/src/main/resources/assets/mcheli/textures/particle/glow.png differ diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-1.21.11/src/main/resources/data/mcheli/damage_type/bullet.json new file mode 100644 index 00000000..92ee6766 --- /dev/null +++ b/minecraft-1.21.11/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-1.21.11/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json b/minecraft-1.21.11/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json new file mode 100644 index 00000000..610afa08 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/chain.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/chain.json new file mode 100644 index 00000000..484f5463 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/container.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/container.json new file mode 100644 index 00000000..4c3bdcd9 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "#c:chests/wooden", + "I": "#c:ingots/iron" + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/drafting_table.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/drafting_table.json new file mode 100644 index 00000000..f3d6324e --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "minecraft:crafting_table", + "F": "#minecraft:wooden_fences", + "P": "#minecraft:planks", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fgm148.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fgm148.json new file mode 100644 index 00000000..c2c61fd6 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fgm148_bullet.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fgm148_bullet.json new file mode 100644 index 00000000..c87b4800 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fim92.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fim92.json new file mode 100644 index 00000000..33a068e1 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fim92_bullet.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fim92_bullet.json new file mode 100644 index 00000000..c472fd6e --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fuel.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fuel.json new file mode 100644 index 00000000..d076b041 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/fuel.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "C": "minecraft:coal", + "I": "#c:ingots/iron" + }, + "pattern": [ + "ICI", + "III" + ], + "result": { + "id": "mcheli:fuel" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/gltd.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/gltd.json new file mode 100644 index 00000000..104ac108 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": "#c:storage_blocks/iron", + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_green.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_green.json new file mode 100644 index 00000000..1046d9cf --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:green_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:item_model": "mcheli:m18_green", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_red.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_red.json new file mode 100644 index 00000000..f98ac51f --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:red_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:item_model": "mcheli:m18_red", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_violet.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_violet.json new file mode 100644 index 00000000..b05f48a7 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:purple_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:item_model": "mcheli:m18_violet", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_yellow.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_yellow.json new file mode 100644 index 00000000..57ecd202 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:yellow_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:item_model": "mcheli:m18_yellow", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/parachute.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/parachute.json new file mode 100644 index 00000000..69eab191 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/parachute.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "S": "#c:strings", + "W": "#minecraft:wool" + }, + "pattern": [ + "WWW", + "S S", + " W " + ], + "result": { + "id": "mcheli:parachute" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/rangefinder.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/rangefinder.json new file mode 100644 index 00000000..58a13385 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json new file mode 100644 index 00000000..d6bac29a --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + "mcheli:rangefinder", + "#c:dusts/redstone" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/uav_station.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/uav_station.json new file mode 100644 index 00000000..24b97d55 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:storage_blocks/redstone" + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/uav_station2.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/uav_station2.json new file mode 100644 index 00000000..74bf0d20 --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/wrench.json b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/wrench.json new file mode 100644 index 00000000..b8c9bcff --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/mcheli/recipe/wrench.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + " I ", + " II", + "I " + ], + "result": { + "id": "mcheli:wrench" + } +} diff --git a/minecraft-1.21.11/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-1.21.11/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json new file mode 100644 index 00000000..1042db9e --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "mcheli:bullet" + ] +} diff --git a/minecraft-1.21.11/src/main/resources/mcheli_logo.png b/minecraft-1.21.11/src/main/resources/mcheli_logo.png new file mode 100644 index 00000000..95c72198 Binary files /dev/null and b/minecraft-1.21.11/src/main/resources/mcheli_logo.png differ diff --git a/minecraft-1.21.11/src/main/resources/pack.mcmeta b/minecraft-1.21.11/src/main/resources/pack.mcmeta new file mode 100644 index 00000000..f49105ce --- /dev/null +++ b/minecraft-1.21.11/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "${mod_name} resources", + "min_format": 82, + "max_format": 94 + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 00000000..ff258847 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 00000000..c88ad40a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; +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.block.DispenserBlock; +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() { + } + + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + + /** + * 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. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(player); + } + List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() + .filter(holder -> holder.id().location().getNamespace().equals(MCHeli.MOD_ID)) + .toList(); + player.awardRecipes(ours); + } + + public static void playerLoggedOut(ServerPlayer player) { + PackSync.onLogout(player); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ContentPacks.java new file mode 100644 index 00000000..d3bfdee5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,187 @@ +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 com.jnhprojects.mcheli.content.pack.PendingPacks; +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.HashSet; +import java.util.List; +import java.util.Set; +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 static final String SOUNDS = "assets/mcheli/sounds/"; + + private final Path folder; + private final PendingPacks pending; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); + this.legacy = legacy; + } + + /** {@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); + PendingPacks.cleanReplaced(folder); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + sounds = soundsOf(accepted); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + + private static @Nullable String refusal(ContentPack pack, long size, long total) { + if (!ServerConfig.isPackAllowed(pack.id())) { + return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; + } + 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-1.21.5/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 00000000..7846b7bf --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/LegacyContent.java new file mode 100644 index 00000000..495bb502 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -0,0 +1,239 @@ +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.List; +import java.util.Locale; +import java.util.Optional; +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.MODERN; + + private final Path folder; + private final Path packRoot; + private @Nullable Component problem; + + 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); + } + + /** 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.5/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 00000000..1a890651 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; +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.ModMenus; +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); + ModBlocks.init(); + ModBlockEntities.init(); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModMenus.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.5/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 00000000..1384fcc4 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,262 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * {@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
+ *   /mcheli give <players> <vehicle> [count]   (operators)
+ *   /mcheli reload                       re-read the definitions and packs (operators)
+ *   /mcheli pending                      packs players have sent, waiting (operators)
+ *   /mcheli approve <pack>               put a waiting pack in use (operators)
+ *   /mcheli reject <pack>                throw a waiting pack away (operators)
+ * 
+ */ +public final class McheliCommand { + private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); + private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); + + private McheliCommand() { + } + + 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)) + .then(Commands.literal("pending") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + } + + private static int status(CommandContext ctx) { + 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); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + + 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.5/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ModSounds.java new file mode 100644 index 00000000..b67d71ab --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -0,0 +1,64 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.core.Holder; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +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. + * + *

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. + */ +public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + + private static final Map EVENTS = new ConcurrentHashMap<>(); + + private ModSounds() { + } + + 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) { + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); + } + + /** Plays {@code name} at a position for everyone nearby. Call on the server. */ + public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } + } + + /** Plays {@code name} for one player only, such as a hit confirmation. */ + public static void playTo(ServerPlayer player, String name, float volume, float pitch) { + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ServerConfig.java new file mode 100644 index 00000000..9db31738 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,172 @@ +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.BooleanValue INFINITE_FUEL; + 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; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; + + 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()); + INFINITE_FUEL = builder + .comment("Whether vehicles never run out of fuel. Original: InfinityFuel") + .define("infiniteFuel", false); + 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); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); + builder.pop(); + + SPEC = builder.build(); + } + + 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(); + } + + public static boolean infiniteFuel() { + return SPEC.isLoaded() ? INFINITE_FUEL.get() : INFINITE_FUEL.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 allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 00000000..8545d2b5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.block; + +import com.mojang.serialization.MapCodec; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + private static final MapCodec CODEC = simpleCodec(DraftingTableBlock::new); + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.column(16.0, 0.0, 15.0); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 00000000..56238f3f --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 00000000..ab62e120 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 00000000..70bcc23e --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 00000000..96d32987 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,330 @@ +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.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ParachuteRenderer; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; +import com.jnhprojects.mcheli.client.screen.VehicleScreen; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; +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.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.network.chat.Component; +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.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; + +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 static boolean shaderProgramsAssigned; + + private ClientHooks() { + } + + /** Loads a post effect into the game renderer, whose own method is private on 1.21.5. */ + public interface PostEffects { + void load(ResourceLocation effect); + } + + private static PostEffects postEffects = effect -> { + }; + + static PostEffects postEffects() { + return postEffects; + } + + /** During mod construction, on a client only. */ + public static void init(PostEffects effects) { + postEffects = effects; + 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); + renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); + } + + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + + /** On the main thread during client setup, once our menu types are registered. */ + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::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() { + if (!shaderProgramsAssigned) { + // On the first tick rather than at construction, so Iris has surely loaded by now. + shaderProgramsAssigned = true; + Glow.assignShaderPrograms(); + } + CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); + VehicleCamera.tick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + TargetMarkers.tick(); + WorkshopClient.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.setScreen(new TeamManagerScreen()); + } else { + mc.player.displayClientMessage(Component.translatable("message.mcheli.team.operators_only"), true); + } + } + + /** 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); + GltdController.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; + } + } + + /** The field of view for this frame, narrowed while the player zooms through a sight. */ + public static float fov(float fov) { + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); + } + + /** 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() || GltdController.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(); + } + + /** The partial tick the camera entity is drawn at, for the loaders whose event does not carry it. */ + public static float cameraPartialTicks() { + return Minecraft.getInstance().gameRenderer.getMainCamera().getPartialTickTime(); + } + + public static void beforeRenderLiving(LivingEntityRenderState state, PoseStack pose, float partialTicks) { + RiderPose.beforeRender(state, pose, partialTicks); + } + + public static void drawHud(GuiGraphics graphics, DeltaTracker delta) { + WeaponHud.draw(graphics, delta); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.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(message -> player.displayClientMessage(message, false)); + } + + public static void loggedOut() { + ClientPacks.INSTANCE.loggedOut(); + WorkshopClient.loggedOut(); + Markers.clear(); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java new file mode 100644 index 00000000..7e7d61b1 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,338 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +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 com.jnhprojects.mcheli.network.PackSoundPacket; +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. + */ +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + 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(); + } + } + + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + 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.displayClientMessage(message, false); + } 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/CrewController.java new file mode 100644 index 00000000..32af6770 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -0,0 +1,482 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +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.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Input; +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; + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } + 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; + VehicleCamera.reset(); + } + + Input keys = player.input.keyPresses; + boolean jumpPressed = keys.jump() && !jumpWasDown; + jumpWasDown = keys.jump(); + boolean pilot = seat == 0 && vehicle.getControllingPassenger() == player; + + if (pilot) { + AircraftEntity.ControlInput input = vehicle.input(); + input.throttleUp = keys.forward(); + input.throttleDown = keys.backward(); + input.left = keys.left(); + input.right = keys.right(); + input.freeLook = isFreeLook(vehicle); + input.brake = vehicle instanceof TankEntity && keys.jump(); + 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.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } + 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 (looksThroughSight(vehicle, seat)) { + VehicleCamera.onZoomKey(player, vehicle); + } else if (pilot) { + send(vehicle, CrewActionPacket.Action.FOLD); + } + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } + } + while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { + if (!vehicle.isDestroyed()) { + send(vehicle, CrewActionPacket.Action.VEHICLE_SCREEN); + } + } + 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); + } + } + } + + /** + * Whether this seat sees through a sight that can zoom: a camera position or gunner sight, the + * pilot in gunner mode, or the pilot of a UAV. The zoom key folds the wings everywhere else. + */ + private static boolean looksThroughSight(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount() || def.cameraZoom <= 1) { + return false; + } + return eyePosition(vehicle, def, seat) != null || vehicle.isGunnerMode() || def.uav; + } + + 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; + } + + 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.5/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 00000000..a8db0832 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +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; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.screen == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.drawString(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.drawString(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java new file mode 100644 index 00000000..dc594e26 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -0,0 +1,49 @@ +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); + /** Zooms through a sight, as the original Z key did; otherwise 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); + /** Fuel and parachute slots, reloading and the cargo chest. The original used R, which switches seats here. */ + public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); + /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ + public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, 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, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); + + private KeyBindings() { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 00000000..71044604 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,239 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +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.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.screen == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(RenderType::guiTextured, ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), + (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.drawString(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.drawString(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.drawString(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.drawString(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.drawString(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.drawString(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 00000000..9ec9ed76 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,156 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +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.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final ResourceLocation SCOPE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(RenderType::guiTextured, SCOPE, (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushPose(); + g.pose().translate(w / 2.0F, h / 2.0F, 0.0F); + g.pose().scale(k, k, 1.0F); + drawReadouts(g, mc.font, player, stack); + g.pose().popPose(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphics g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.drawString(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.drawString(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.drawString(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.drawString(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.drawString(font, ">", 30, y, WHITE); + } + g.drawString(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 00000000..d21a189a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,165 @@ +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(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + 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); + } + + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(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); + } + } + + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(ResourceLocation id) { + TEXTURES.put(id, upload(id)); + } + + 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(id::toString, 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) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } + for (Path root : roots) { + Path file = inRoot(root, path); + if (file != null) { + return file; + } + } + return null; + } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 00000000..a7d8f86a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +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.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.joml.Vector4f; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphics g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta.getGameTimeDeltaPartialTick(false); + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "â–¼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "â—†", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphics g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.getMainCamera(); + Vector3fc forward = camera.getLookVector(); + Vec3 offset = at.subtract(camera.getPosition()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + // 26.x has projectPointToScreen for this; the view bobbing and the sprint widening are left out. + Vector3f view = new Vector3f((float) offset.x, (float) offset.y, (float) offset.z).rotate(camera.rotation().conjugate(new Quaternionf())); + Vector4f clip = mc.gameRenderer.getProjectionMatrix(ClientHooks.fov(mc.options.fov().get())) + .transform(new Vector4f(view, 1.0F)); + Vec3 ndc = new Vec3(clip.x() / clip.w(), clip.y() / clip.w(), clip.z() / clip.w()); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.drawCenteredString(font, symbol, (int) x, (int) y - 4, color); + g.drawCenteredString(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.drawCenteredString(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.drawCenteredString(font, "â—†", ex, ey - 4, color); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 00000000..1130e270 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,127 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +import java.util.function.Consumer; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision and thermal view for those with {@code EnableNightVision}. Both + * reset whenever the player changes seat or leaves the vehicle. + */ +public final class VehicleCamera { + /** The original's camera modes; the HUD reads the ordinal as {@code cam_mode}. */ + public enum Mode { + NORMAL, + NIGHT_VISION, + /** Night vision seen through a white hot filter, the original's "pencil" shader mode. */ + THERMAL + } + + /** The post effect of the thermal view, {@code assets/mcheli/post_effect/thermal.json}. */ + private static final ResourceLocation THERMAL_EFFECT = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "thermal"); + + private static float zoom = 1.0F; + private static Mode mode = Mode.NORMAL; + + private VehicleCamera() { + } + + public static float zoom() { + return zoom; + } + + public static Mode mode() { + return mode; + } + + /** Back to the plain view, on a seat change or after leaving the vehicle. */ + static void reset() { + zoom = 1.0F; + mode = Mode.NORMAL; + } + + /** + * Keeps the thermal filter in the player's post effects while the thermal view is on. The list + * is the one a server's post effect packet fills, so ours is put back if that replaces it. + */ + static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + boolean active = THERMAL_EFFECT.equals(mc.gameRenderer.currentPostEffect()); + if (mode == Mode.THERMAL && !active) { + ClientHooks.postEffects().load(THERMAL_EFFECT); + } else if (mode != Mode.THERMAL && active) { + mc.gameRenderer.clearPostEffect(); + } + } + + /** + * {@code zoomCamera}: each press doubles the zoom up to the vehicle's {@code CameraZoom}, and + * the press after that goes back to one. The caller checks that the seat looks through a sight. + */ + static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { + refuse(player); + return; + } + zoom = zoom >= max - 0.01F ? 1.0F : Math.min(zoom * 2.0F, max); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + + /** + * {@code switchCameraMode}: steps from the plain view to night vision, to thermal view and + * back. The server applies night vision as an effect in both of the last two; the thermal + * filter is drawn here. + */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { + Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; + if (mode == Mode.NORMAL || next == Mode.NORMAL) { + nightVision.accept(next != Mode.NORMAL); + } + mode = next; + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + + /** The view's field of view, narrowed by the zoom. */ + public static float fov(float fov) { + return zoom > 1.0F ? fov / zoom : fov; + } + + /** {@code playSoundNG}: the key does nothing in this seat. */ + private static void refuse(LocalPlayer player) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java new file mode 100644 index 00000000..9507412f --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java new file mode 100644 index 00000000..d46804e6 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -0,0 +1,187 @@ +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(); + } + } + + 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.5/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java new file mode 100644 index 00000000..347ac6f1 --- /dev/null +++ b/minecraft-1.21.5/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.VEHICLE_SCREEN.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.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java new file mode 100644 index 00000000..05be6d57 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java @@ -0,0 +1,165 @@ +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.client.renderer.RenderType; +import net.minecraft.resources.ResourceLocation; + +/** + * The drawing primitives of {@code MCH_HudItem}, on the 1.21.5 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(RenderType::guiTextured, texture, 0, 0, (float) u, (float) v, 1, 1, (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.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java new file mode 100644 index 00000000..458ee88e --- /dev/null +++ b/minecraft-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java new file mode 100644 index 00000000..5a670dbd --- /dev/null +++ b/minecraft-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java new file mode 100644 index 00000000..46cabfc7 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -0,0 +1,373 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.client.VehicleCamera; +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 read as their idle state for now: 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; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; + 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; + private int fuelWarnCount; + + /** 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.getMinY()), mob.getBlockZ()); + int blocks = 0; + for (; pos.getY() < level.getMaxY(); 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.getMinY(); 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, vehicle.fuelRatio()); + v.set(HudVariable.LOW_FUEL, lowFuel(vehicle)); + 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, VehicleCamera.mode().ordinal()); + v.set(HudVariable.CAM_ZOOM, VehicleCamera.zoom()); + 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); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); + } + + /** + * {@code isLowFuel}: below 10% the warning blinks, on for eight seconds out of every fourteen. + * Ticks with each update, as the original counted per frame. + */ + private boolean lowFuel(AircraftEntity vehicle) { + if (fuelWarnCount <= 0) { + fuelWarnCount = 280; + } + fuelWarnCount--; + return fuelWarnCount < 160 && vehicle.maxFuel() > 0 && vehicle.fuelRatio() < 0.1F; + } + + /** 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); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; + } else { + vars.set(HudVariable.IS_HEAT_WPN, 0.0); + vars.set(HudVariable.RELOADING, 0.0); + vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; + } + 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, mortarDistance); + } + + 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 -> mortarDistance; + case MC_VER -> "1.21.5"; + 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 -> VehicleCamera.zoom(); + case UAV_DIST -> Math.max(vehicle.stationDistance(), 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.5/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 00000000..8d314c9d --- /dev/null +++ b/minecraft-1.21.5/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.ARGB; +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(ARGB.red(color) / 255.0F, ARGB.green(color) / 255.0F, ARGB.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.5/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 00000000..4d1763d1 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends TextureSheetParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z); + setSprite(sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor((ARGB.red(options.color()) / 255.0F), (ARGB.green(options.color()) / 255.0F), (ARGB.blue(options.color()) / 255.0F)); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void render(VertexConsumer buffer, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.render(buffer, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + public ParticleRenderType getRenderType() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, level.random); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java new file mode 100644 index 00000000..52dc21b8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java @@ -0,0 +1,59 @@ +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.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +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; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + boolean visible; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(A10Entity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = entity.isVisible(); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.get("", "a-10") : null; + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(-state.pitch)); + int light = packedLight; + Geometry.submit(buffers, pose, RenderType.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java new file mode 100644 index 00000000..e5e78ade --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -0,0 +1,84 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.world.phys.AABB; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Snapshot of a vehicle for one frame, filled in by {@link AircraftRenderer#extractRenderState}. + * Every angle is already interpolated for the frame's partial tick. + */ +public class AircraftRenderState extends EntityRenderState { + /** The hull boxes to outline while F3+B is on, relative to the entity. */ + public final List hull = new ArrayList<>(); + public int hullListed; + /** 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; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; + public float throttle; + + // Part states, 0 to 90 degrees + public float canopy; + public float landingGear; + public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; + 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; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; + + /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ + public WeaponPartState[] weaponParts = new WeaponPartState[0]; + /** 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.5/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java new file mode 100644 index 00000000..028e1e66 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -0,0 +1,347 @@ +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.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; +import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; +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.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.ShapeRenderer; +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.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * 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 { + private static final float[] HULL_COLOR = {0.0F, 0.88F, 1.0F}; + private static final float[] GENERATED_COLOR = {1.0F, 0.75F, 0.0F}; + /** Ropes take the colour of brown wool. */ + private static final ResourceLocation ROPE = ResourceLocation.withDefaultNamespace("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; + + public AircraftRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 2.0F; + } + + /** + * With F3+B on, outlines the hull boxes next to vanilla's main box: what projectiles hit and + * what other vehicles collide with. The definition's own boxes are cyan, the ones generated + * from the model amber. + */ + private static void showHull(AircraftRenderState state, PoseStack pose, MultiBufferSource buffers) { + if (state.hull.isEmpty()) { + return; + } + VertexConsumer lines = buffers.getBuffer(RenderType.lines()); + for (int i = 0; i < state.hull.size(); i++) { + float[] c = i < state.hullListed ? HULL_COLOR : GENERATED_COLOR; + ShapeRenderer.renderLineBox(pose, lines, state.hull.get(i), c[0], c[1], c[2], 1.0F); + } + } + + @Override + public AircraftRenderState createRenderState() { + return new AircraftRenderState(); + } + + @Override + public void extractRenderState(T entity, AircraftRenderState state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + 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.hull.clear(); + if (entityRenderDispatcher.shouldRenderHitBoxes()) { + // The first is the main box, which vanilla draws itself; then the listed boxes, then the generated ones. + List hull = entity.hullBoxes(); + Vec3 offset = entity.getPosition(partialTicks); + for (int i = 1; i < hull.size(); i++) { + state.hull.add(hull.get(i).move(-offset.x, -offset.y, -offset.z).move(offset.subtract(entity.position()))); + } + state.hullListed = def.boundingBoxes.size(); + } + + 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 HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getXRot(partialTicks) : state.pitch; + } + 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, getPackedLightCoords(entity, partialTicks)); + 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 + protected AABB getBoundingBoxForCulling(T entity) { + // The hit box is far smaller than most models, so cull on a generous box instead. + 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(AircraftRenderState state, PoseStack poseStack, MultiBufferSource buffers, int packedLight) { + draw(state, poseStack, buffers, packedLight); + super.render(state, poseStack, buffers, packedLight); + } + + /** Draws the vehicle, without the name tag and the like a world entity also gets. The workshop preview uses this. */ + public static void draw(AircraftRenderState state, PoseStack poseStack, MultiBufferSource buffers, int packedLight) { + AircraftDefinition def = state.definition; + showHull(state, poseStack, buffers); + 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), packedLight, + OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, buffers, packedLight); + } + if (state.searchLight) { + searchLights(def, state, poseStack, buffers); + } + poseStack.popPose(); + } + } + + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.mulPose(Axis.YN.rotationDegrees(state.searchLightYaw - state.yaw + light.yaw())); + pose.mulPose(Axis.XP.rotationDegrees(state.searchLightPitch + 90.0F - state.pitch + light.pitch())); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.mulPose(Axis.YN.rotationDegrees(light.yaw() + steer)); + pose.mulPose(Axis.XP.rotationDegrees(90.0F + light.pitch())); + } + Glow.cone(pose, buffers, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, MultiBufferSource buffers, int light) { + float length = state.ropeLength; + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(ROPE), (p, buffer) -> { + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + }); + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.addVertex(p, x, y, z).setColor(0xFFFFFFFF).setUv(u, v).setOverlay(OverlayTexture.NO_OVERLAY).setLight(light).setNormal(p, 0.0F, 1.0F, 0.0F); + } + + 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.5/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 00000000..98a9b608 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +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.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + /** Towed end, relative to the chain entity. */ + Vec3 from = Vec3.ZERO; + /** From the towed end to the end that pulls. */ + Vec3 span = Vec3.ZERO; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + protected AABB getBoundingBoxForCulling(ChainEntity chain) { + AABB box = super.getBoundingBoxForCulling(chain); + Entity towed = chain.towed(); + return towed != null ? box.minmax(towed.getBoundingBox()) : box; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ChainEntity chain, State state, float partialTicks) { + super.extractRenderState(chain, state, partialTicks); + Entity tow = chain.tow(); + Entity towed = chain.towed(); + if (tow == null || towed == null) { + state.span = Vec3.ZERO; + return; + } + Vec3 here = chain.getPosition(partialTicks); + Vec3 start = towed.getPosition(partialTicks); + state.from = start.subtract(here); + state.span = tow.getPosition(partialTicks).subtract(start); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + double length = state.span.length(); + if (mesh != null && length > LINK) { + Vec3 step = state.span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + int light = packedLight; + pose.pushPose(); + pose.translate(state.from); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-yaw)); + pose.mulPose(Axis.ZP.rotationDegrees(pitch)); + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, GREY)); + pose.popPose(); + } + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 00000000..69812a0e --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,38 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +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.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public void render(DraftingTableBlockEntity table, float partialTicks, PoseStack pose, MultiBufferSource buffers, int light, int overlay, Vec3 camera) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.mulPose(Axis.YP.rotationDegrees(-table.getBlockState().getValue(DraftingTableBlock.ROTATION) * 45.0F + 180.0F)); + MeshRenderer.emit(mesh.groups(), pose.last(), buffers.getBuffer(RenderType.entityCutout(TEXTURE)), light, OverlayTexture.NO_OVERLAY, TINT); + pose.popPose(); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 00000000..331e4d31 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +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.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final ResourceLocation texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(T entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.pitch)); + int light = packedLight; + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, tint)); + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 00000000..2ca11c16 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java @@ -0,0 +1,52 @@ +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.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.world.phys.Vec3; + +/** + * 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; + } + + public static class State extends EntityRenderState { + float glow; + float flicker = 1.0F; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(FlareEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.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. + state.flicker = 0.8F + 0.2F * (float) Math.sin((entity.tickCount + partialTicks) * 1.7F + entity.getId()); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera(); + Glow.halo(pose, buffers, camera, Vec3.ZERO, RADIUS * state.flicker, GlowParticleOptions.FIRE, state.glow); + Glow.halo(pose, buffers, camera, Vec3.ZERO, RADIUS * 0.35F, GlowParticleOptions.FLASH, state.glow); + super.render(state, pose, buffers, packedLight); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/Geometry.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/Geometry.java new file mode 100644 index 00000000..24181799 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/Geometry.java @@ -0,0 +1,24 @@ +package com.jnhprojects.mcheli.client.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; + +/** + * Draws straight into the frame's buffers. Later versions queue geometry for a submit step instead, + * so this keeps the renderers reading the same on every version. + */ +final class Geometry { + private Geometry() { + } + + /** Fills a buffer of {@code type} with what {@code renderer} emits, at the pose as it is now. */ + static void submit(MultiBufferSource buffers, PoseStack pose, RenderType type, Renderer renderer) { + renderer.emit(pose.last(), buffers.getBuffer(type)); + } + + interface Renderer { + void emit(PoseStack.Pose pose, VertexConsumer buffer); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java new file mode 100644 index 00000000..f21bbd2c --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.resources.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 final Map, Map, List>> buckets = 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)); + } + + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + + void clear() { + textures.clear(); + splits.clear(); + buckets.clear(); + } + + private Split compute(List groups, 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.getPixel(x, y) >>> 24); + } + } + return new Alpha(w, h, values); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 00000000..d517866a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,224 @@ +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.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.particle.ParticleRenderType; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.util.TriState; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; + +/** + * 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. + * With order independent transparency on, the vanilla translucent pipelines stand in. + */ +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"); + + private static final RenderPipeline PARTICLE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow_particle")) + .withBlend(BlendFunction.LIGHTNING) + .withDepthWrite(false) + .build(); + + /** + * Vanilla's translucent particles with depth writes off. A puff's clear edge would otherwise + * hide the puffs drawn after it, and a smoke cloud is mostly overlapping puffs. + */ + private static final RenderPipeline SMOKE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/smoke_particle")) + .withBlend(BlendFunction.TRANSLUCENT) + .withDepthWrite(false) + .build(); + + private static final RenderPipeline ENTITY_PIPELINE = RenderPipeline.builder(RenderPipelines.MATRICES_COLOR_FOG_SNIPPET) + .withLocation(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow")) + .withVertexShader("core/entity") + .withFragmentShader("core/entity") + .withShaderDefine("EMISSIVE") + .withShaderDefine("NO_OVERLAY") + .withShaderDefine("NO_CARDINAL_LIGHTING") + .withSampler("Sampler0") + .withVertexFormat(DefaultVertexFormat.NEW_ENTITY, VertexFormat.Mode.QUADS) + .withBlend(BlendFunction.LIGHTNING) + .withDepthWrite(false) + .build(); + + /** The particle render type for {@code mcheli:glow}. */ + public static final ParticleRenderType PARTICLE_LAYER = new ParticleRenderType("MCHELI_GLOW", Types.particle("glow_particle", PARTICLE_PIPELINE)); + + /** The particle render type for {@code mcheli:smoke}. */ + public static final ParticleRenderType SMOKE_LAYER = new ParticleRenderType("MCHELI_SMOKE", Types.particle("smoke_particle", SMOKE_PIPELINE)); + + private static final RenderType HALO = Types.halo(); + + private static final int FULL_BRIGHT = LightTexture.FULL_BRIGHT; + + private Glow() { + } + + + /** Only a render type sees the protected shards these are built from. */ + private abstract static class Types extends RenderType { + private Types() { + super("", 0, false, false, () -> { + }, () -> { + }); + } + + static RenderType particle(String name, RenderPipeline pipeline) { + return create(MCHeli.MOD_ID + "_" + name, TRANSIENT_BUFFER_SIZE, false, false, pipeline, CompositeState.builder() + .setTextureState(new TextureStateShard(TextureAtlas.LOCATION_PARTICLES, TriState.FALSE, false)) + .setOutputState(PARTICLES_TARGET) + .setLightmapState(LIGHTMAP) + .createCompositeState(false)); + } + + static RenderType halo() { + return create(MCHeli.MOD_ID + "_glow", TRANSIENT_BUFFER_SIZE, false, true, ENTITY_PIPELINE, CompositeState.builder() + .setTextureState(new TextureStateShard(TEXTURE, TriState.FALSE, false)) + .createCompositeState(false)); + } + } + + /** + * Hands our pipelines to Iris, so a shader pack draws them with a matching program: the halos + * and tracers as emissive entities, the glow and smoke particles as translucent particles. + * Called once the mods are loaded; without Iris it does nothing. + */ + public static void assignShaderPrograms() { + IrisCompat.assign(ENTITY_PIPELINE, "EMISSIVE_ENTITIES"); + IrisCompat.assign(PARTICLE_PIPELINE, "PARTICLES_TRANSLUCENT"); + IrisCompat.assign(SMOKE_PIPELINE, "PARTICLES_TRANSLUCENT"); + } + + /** + * 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 color RGB; {@code strength} becomes the alpha, which scales the light added + */ + public static void halo(PoseStack pose, MultiBufferSource buffers, Camera camera, Vec3 offset, float radius, int color, + float strength) { + if (strength <= 0.0F || radius <= 0.0F) { + return; + } + int argb = ARGB.color(alpha(strength), color); + pose.pushPose(); + pose.translate(offset); + pose.mulPose(camera.rotation()); + Geometry.submit(buffers, pose, HALO, (p, buffer) -> { + // 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 = ARGB.color(alpha(strength), color); + int end = ARGB.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; + Geometry.submit(buffers, pose, HALO, (p, buffer) -> { + // 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); + }); + } + + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, MultiBufferSource buffers, float height, float radius, int start, int end) { + Geometry.submit(buffers, pose, HALO, (p, buffer) -> { + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + }); + } + + 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); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 00000000..91619d88 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,106 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +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.Minecraft; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final ResourceLocation TEXTURE = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float headYaw; + float headPitch; + boolean drawHead = true; + boolean laser; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(GltdEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + state.laser = viewer != null; + state.drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + state.headYaw = viewer != null ? viewer.getYRot(partialTicks) : state.yaw; + state.headPitch = viewer != null ? viewer.getXRot(partialTicks) : 0.0F; + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera(); + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + int light = packedLight; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + draw(mesh.part("$body"), pose, buffers, light); + pose.popPose(); + if (state.drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.headYaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.headPitch)); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, buffers, light); + pose.popPose(); + } + } + if (state.laser) { + float yaw = state.headYaw * Mth.DEG_TO_RAD; + float pitch = state.headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = new Vec3(state.x, state.y, state.z).add(head).subtract(camera.getPosition()); + pose.pushPose(); + pose.translate(head); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, buffers, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } + + private static void draw(List groups, PoseStack pose, MultiBufferSource buffers, int light) { + if (!groups.isEmpty()) { + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java new file mode 100644 index 00000000..3803102e --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java @@ -0,0 +1,49 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.pipeline.RenderPipeline; + +import java.lang.reflect.Method; + +/** + * Tells Iris which shader program draws our own render pipelines. Iris only knows the vanilla + * pipelines and draws any other with a program whose vertex layout does not match, which throws + * the vertices far off: the glows turn into long coloured lines running to the horizon. Iris is + * optional, so its API is looked up by reflection instead of compiled against. + */ +final class IrisCompat { + private static final String API = "net.irisshaders.iris.api.v0.IrisApi"; + private static final String PROGRAM = "net.irisshaders.iris.api.v0.IrisProgram"; + + private IrisCompat() { + } + + /** + * Assigns {@code pipeline} to the Iris program named {@code program}, such as + * {@code EMISSIVE_ENTITIES}. Does nothing without Iris. + * + * @return whether Iris took it + */ + static boolean assign(RenderPipeline pipeline, String program) { + Class api; + try { + api = Class.forName(API); + } catch (ClassNotFoundException absent) { + MCHeli.LOGGER.debug("Iris is not installed, {} keeps its own shader", pipeline.getLocation()); + return false; + } + try { + Class programs = Class.forName(PROGRAM); + Object instance = api.getMethod("getInstance").invoke(null); + Object target = programs.getField(program).get(null); + Method assign = api.getMethod("assignPipeline", RenderPipeline.class, programs); + assign.invoke(instance, pipeline, target); + MCHeli.LOGGER.debug("Iris draws {} with its {} program", pipeline.getLocation(), program); + return true; + } catch (ReflectiveOperationException | LinkageError e) { + // An Iris without this API: glows may look wrong under a shader pack, nothing worse. + MCHeli.LOGGER.warn("Could not hand {} to Iris, glows may look wrong with shaders: {}", pipeline.getLocation(), e.toString()); + return false; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java new file mode 100644 index 00000000..1fc66d2f --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java new file mode 100644 index 00000000..47cc66e3 --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 00000000..8bbc1eab --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; + +import java.util.List; + +/** + * {@code MCH_RenderParachute}: the item's round canopy, or the ejection seat with its canopy, + * from the models the import brought in. + */ +public class ParachuteRenderer extends EntityRenderer { + private static final ResourceLocation ROUND = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute1.png"); + private static final ResourceLocation SEAT = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute2.png"); + + public ParachuteRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + ParachuteEntity.Kind kind = ParachuteEntity.Kind.ITEM; + boolean open; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ParachuteEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.open = entity.isOpen(); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + List groups = groups(state); + if (!groups.isEmpty()) { + ResourceLocation texture = state.kind == ParachuteEntity.Kind.ITEM ? ROUND : SEAT; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = packedLight; + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } + + private static List groups(State state) { + if (state.kind == ParachuteEntity.Kind.ITEM) { + Mesh mesh = MeshCache.INSTANCE.get("", "parachute1"); + return mesh != null ? mesh.groups() : List.of(); + } + Mesh mesh = MeshCache.INSTANCE.get("", "parachute2"); + boolean canopy = state.kind == ParachuteEntity.Kind.DROP || state.open; + return mesh != null ? mesh.part(canopy ? "$parachute" : "$seat") : List.of(); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java new file mode 100644 index 00000000..f8f1eb4f --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -0,0 +1,554 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +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.jnhprojects.mcheli.model.PartTextures; +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.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); + hull(); + } + case HelicopterDefinition heli -> { + hull(); + rotors(heli); + } + case VehicleDefinition vehicle -> { + hull(); + for (VehiclePart part : vehicle.parts) { + vehiclePart(vehicle, part); + } + } + default -> hull(); + } + weapons(); + rotatingParts(); + movingParts(def.hatches, state.hatch); + trackRollers(); + crawlerTracks(); + steeringWheels(); + wheels(); + throttles(); + cameras(); + landingGear(); + weaponBays(); + movingParts(def.canopies, state.canopy); + } + + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + + // Kind specific parts + + private void rotors(HelicopterDefinition heli) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); + 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); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : 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; + } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + ResourceLocation own = RuntimeAssets.texture(ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderType.entityCutout(own), RenderType.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, ResourceLocation texture, RenderType opaqueType, RenderType glassType) { + GlassSplit.Split split = GlassSplit.INSTANCE.split(groups, texture); + if (!split.opaque().isEmpty()) { + Geometry.submit(buffers, pose, opaqueType, (p, buffer) -> MeshRenderer.emit(split.opaque(), p, buffer, light, overlay)); + } + if (!split.glass().isEmpty()) { + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + Geometry.submit(buffers, pose, glassType, state.inGui + ? (p, buffer) -> MeshRenderer.emit(split.glass(), p, buffer, light, overlay, glassColor) + : (p, buffer) -> MeshRenderer.emitFrontFaces(split.glass(), p, buffer, light, overlay, glassColor)); + } + } + + 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.5/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java new file mode 100644 index 00000000..5fed3cd5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -0,0 +1,168 @@ +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.Camera; +import net.minecraft.client.Minecraft; +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.entity.state.EntityRenderState; +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.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** + * 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 + } + + public static class State extends EntityRenderState { + 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; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ProjectileEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.model = entity.model(); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = !entity.isSpent() && (!(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 || entity.isSpent() ? 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(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera(); + 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)); + int color = state.color; + Geometry.submit(buffers, pose, RenderType.itemEntityTranslucentCull(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, FULL_BRIGHT, OverlayTexture.NO_OVERLAY, color)); + pose.popPose(); + } + if (state.visible) { + submitGlow(state, pose, buffers, camera); + } + super.render(state, pose, buffers, packedLight); + } + + private static void submitGlow(State state, PoseStack pose, MultiBufferSource buffers, Camera camera) { + 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 toCamera = new Vec3(state.x - camera.getPosition().x, state.y - camera.getPosition().y, state.z - camera.getPosition().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.5/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java new file mode 100644 index 00000000..9555fba8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -0,0 +1,49 @@ +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.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.entity.state.PlayerRenderState; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.world.entity.Entity; +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(LivingEntityRenderState renderState, PoseStack pose, float partialTicks) { + // Render states carry no entity reference; players are the ones that keep their id. + if (!(renderState instanceof PlayerRenderState state)) { + return; + } + ClientLevel level = Minecraft.getInstance().level; + Entity rider = level != null ? level.getEntity(state.id) : null; + if (rider == null || !(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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 00000000..6f2213ac --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,55 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +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.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + String name = ""; + float yaw; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ThrowableEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.name = entity.definitionName(); + state.yaw = entity.getYRot(partialTicks); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + Mesh mesh = state.name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", state.name); + if (mesh != null) { + ResourceLocation texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/throwable/" + state.name + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = packedLight; + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 00000000..9d0e59e2 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.Camera; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + public static class State extends EntityRenderState { + float yaw; + int kind; + boolean lit; + float cover; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(UavStationEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.cover = entity.cover(partialTicks); + state.lit = state.kind == UavStationEntity.PORTABLE ? state.cover > 0.95F : entity.isLinked(); + } + + @Override + public void render(State state, PoseStack pose, MultiBufferSource buffers, int packedLight) { + int index = state.kind == UavStationEntity.PORTABLE ? 1 : 0; + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + ResourceLocation texture = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[index] + (state.lit ? "_on" : "") + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = packedLight; + if (index == 0) { + draw(pose, buffers, texture, mesh.groups(), light); + } else { + draw(pose, buffers, texture, mesh.part("$body"), light); + folding(pose, buffers, texture, mesh.part("$cover"), state.cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, buffers, texture, mesh.part("$laptop_cover"), state.cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, buffers, texture, mesh.part("$display"), state.cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.render(state, pose, buffers, packedLight); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, MultiBufferSource buffers, ResourceLocation texture, List part, + float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.mulPose(Axis.XN.rotationDegrees(angle)); + pose.translate(0.0F, -y, -z); + draw(pose, buffers, texture, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, MultiBufferSource buffers, ResourceLocation texture, List groups, int light) { + if (!groups.isEmpty()) { + Geometry.submit(buffers, pose, RenderType.entityCutoutNoCull(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 00000000..a88e5213 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,337 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.recipebook.RecipeCollection; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedItemContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.RecipeDisplay; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the player's recipe book, which holds every MCHeli recipe from login. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 400; + imageHeight = 240; + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); + selectList(list); + } + + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.setScreen(new WorkshopHomeScreen()); + } + + /** Every MCHeli recipe the recipe book holds, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + for (RecipeCollection collection : minecraft.player.getRecipeBook().getCollections()) { + for (RecipeDisplayEntry entry : collection.getRecipes()) { + ItemStack result = entry.display().result().resolveForFirstStack(context); + if (!result.isEmpty() && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(entry); + } + } + } + for (List entries : lists) { + entries.sort(Comparator.comparing(e -> e.display().result().resolveForFirstStack(context).getHoverName().getString())); + } + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeDisplayEntry current() { + List entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeDisplayEntry entry = current(); + return entry != null ? entry.display().result().resolveForFirstStack(SlotDisplayContext.fromLevel(minecraft.level)) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static ResourceLocation pageTexture(String name, int page) { + return ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeDisplayEntry entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedItemContents contents = new StackedItemContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return entry.canCraft(contents); + } + + private void create() { + RecipeDisplayEntry entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(RenderType::guiTextured, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(RenderType::guiTextured, pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, 0.0F, 0.0F, + PAGE_WIDTH / 2, PAGE_HEIGHT / 2, PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeDisplayEntry entry = current(); + if (entry == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.drawString(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.drawString(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.display(), mouseX, mouseY); + graphics.renderItem(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphics graphics, RecipeDisplay display, int mouseX, int mouseY) { + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + List slots; + int width; + switch (display) { + case ShapedCraftingRecipeDisplay shaped -> { + slots = shaped.ingredients(); + width = shaped.width(); + } + case ShapelessCraftingRecipeDisplay shapeless -> { + slots = shapeless.ingredients(); + width = 3; + } + default -> { + return; + } + } + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + List options = slots.get(i).resolveForStacks(context); + if (options.isEmpty()) { + continue; + } + ItemStack stack = options.get(tick % options.size()); + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.renderItem(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.renderTooltip(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphics graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.drawString(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 00000000..3ec2606f --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,136 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamManager.COLORS.indexOf(ChatFormatting.RED); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamManager.COLORS.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + ChatFormatting teamColor = TeamManager.COLORS.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getName()).withStyle(teamColor))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphics graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.drawString(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.drawString(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 00000000..9dca5645 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,96 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = 166; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + graphics.blit(RenderType::guiTextured, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.drawString(font, label, 8, 6, color, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.drawString(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java new file mode 100644 index 00000000..8969f1b3 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java @@ -0,0 +1,182 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGui}: fuel and parachute slots on the left, the weapon + * panel on the right with its reload cost and button, and a button into the cargo chest for + * vehicles that have one. The panel is drawn from the chest texture so it looks like vanilla. + */ +public class VehicleScreen extends AbstractContainerScreen { + private static final ResourceLocation CHEST = ResourceLocation.withDefaultNamespace("textures/gui/container/generic_54.png"); + private static final ResourceLocation SLOT = ResourceLocation.withDefaultNamespace("container/slot"); + /** Height of the part above the player inventory. */ + private static final int TOP = 88; + private static final int PANEL_X = 72; + private static final int PANEL_WIDTH = 96; + private static final int LABEL = 0xFF404040; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF5555; + private static final int GREEN = 0xFF55FF55; + + private int weapon; + private boolean cargoButton; + private @Nullable Button reload; + private @Nullable Button previous; + private @Nullable Button next; + + public VehicleScreen(VehicleMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + imageWidth = 176; + imageHeight = TOP + 96; + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + previous = addRenderableWidget(Button.builder(Component.literal("<"), b -> step(-1)) + .bounds(leftPos + PANEL_X, topPos + 68, 16, 16).build()); + reload = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.reload"), b -> press(weapon)) + .bounds(leftPos + PANEL_X + 18, topPos + 68, PANEL_WIDTH - 36, 16).build()); + next = addRenderableWidget(Button.builder(Component.literal(">"), b -> step(1)) + .bounds(leftPos + PANEL_X + PANEL_WIDTH - 16, topPos + 68, 16, 16).build()); + AircraftEntity vehicle = menu.vehicle(); + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + cargoButton = def != null && def.cargoRows() > 0; + if (cargoButton) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.cargo"), b -> press(VehicleMenu.BUTTON_CARGO)) + .bounds(leftPos + imageWidth - 52, topPos + 3, 46, 13).build()); + } + updateButtons(); + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + private int weaponCount() { + AircraftEntity vehicle = menu.vehicle(); + VehicleWeapons weapons = vehicle != null ? vehicle.weapons() : null; + return weapons != null ? Math.min(weapons.sets().size(), VehicleMenu.MAX_WEAPONS) : 0; + } + + private void step(int direction) { + int count = weaponCount(); + if (count > 0) { + weapon = Math.floorMod(weapon + direction, count); + updateButtons(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + private void updateButtons() { + if (reload == null || previous == null || next == null) { + return; + } + int count = weaponCount(); + reload.visible = count > 0; + previous.visible = count > 1; + next.visible = count > 1; + reload.active = menu.canReload(weapon); + AircraftEntity vehicle = menu.vehicle(); + boolean grounded = vehicle != null && vehicle.canSupply(); + reload.setTooltip(grounded ? null : Tooltip.create(Component.translatable("gui.mcheli.vehicle.reload_hint"))); + } + + @Override + protected void renderBg(GuiGraphics graphics, float a, int mouseX, int mouseY) { + int x = leftPos; + int y = topPos; + // The chest's title bar, its plain middle stretched down to the inventory, then the inventory. + graphics.blit(RenderType::guiTextured, CHEST, x, y, 0.0F, 0.0F, imageWidth, 17, 256, 256); + graphics.blit(RenderType::guiTextured, CHEST, x, y + 17, 0.0F, 12.0F, imageWidth, TOP - 17, imageWidth, 1, 256, 256); + graphics.blit(RenderType::guiTextured, CHEST, x, y + TOP, 0.0F, 126.0F, imageWidth, 96, 256, 256); + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + graphics.blitSprite(RenderType::guiTextured, SLOT, x + VehicleMenu.FUEL_X + i * 18 - 1, y + VehicleMenu.FUEL_Y - 1, 18, 18); + } + for (int i = 0; i < menu.parachuteSlots(); i++) { + graphics.blitSprite(RenderType::guiTextured, SLOT, x + VehicleMenu.PARACHUTE_X + i * 18 - 1, y + VehicleMenu.PARACHUTE_Y - 1, 18, 18); + } + graphics.fill(x + PANEL_X - 2, y + 17, x + PANEL_X + PANEL_WIDTH + 2, y + 66, 0xFF373737); + } + + @Override + protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { + AircraftEntity vehicle = menu.vehicle(); + // Long names only give way to the Cargo button, where there is one. + int titleWidth = cargoButton ? imageWidth - 64 : imageWidth - 16; + graphics.drawString(font, font.plainSubstrByWidth(title.getString(), titleWidth), titleLabelX, titleLabelY, LABEL, false); + graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, LABEL, false); + if (vehicle == null) { + return; + } + if (vehicle.maxFuel() > 0) { + // A percentage and a gauge: the exact units would run into the weapon panel. + int percent = Math.round(vehicle.fuelRatio() * 100.0F); + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.fuel", percent), VehicleMenu.FUEL_X, VehicleMenu.FUEL_Y - 11, LABEL, false); + int gaugeY = VehicleMenu.FUEL_Y + 18; + int width = VehicleInventory.FUEL_SLOTS * 18 - 2; + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width, gaugeY + 3, 0xFF373737); + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width * percent / 100, gaugeY + 3, percent <= 10 ? RED : GREEN); + } + if (menu.parachuteSlots() > 0) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.parachute"), VehicleMenu.PARACHUTE_X, VehicleMenu.PARACHUTE_Y - 11, LABEL, false); + } + weaponPanel(graphics, vehicle); + } + + /** Name, rounds carried against the maximum, and what one reload costs. */ + private void weaponPanel(GuiGraphics graphics, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null && weaponCount() > 0 ? weapons.set(Math.min(weapon, weaponCount() - 1)) : null; + if (set == null) { + graphics.drawString(font, Component.translatable("gui.mcheli.vehicle.no_weapons"), PANEL_X, 20, WHITE, false); + return; + } + graphics.drawString(font, font.plainSubstrByWidth(set.displayName(), PANEL_WIDTH), PANEL_X, 20, WHITE, false); + int rounds = menu.rounds(weapon); + int max = set.maxAmmo(); + int color = rounds == 0 && max > 0 ? RED : rounds >= max ? GREEN : WHITE; + graphics.drawString(font, rounds + " / " + max, PANEL_X, 31, color, false); + int itemX = PANEL_X; + for (RoundItem cost : set.definition().roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null) { + continue; + } + graphics.renderItem(new ItemStack(item), itemX, 42); + graphics.drawString(font, String.valueOf(cost.count()), itemX + 17, 47, WHITE, false); + itemX += 32; + if (itemX > PANEL_X + PANEL_WIDTH - 16) { + break; + } + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java new file mode 100644 index 00000000..b32040f5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. + * + *

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. + */ +final class EngineSound extends RuntimeSoundInstance { + private static final float MAX_DOPPLER = 0.2F; + + private final AircraftEntity vehicle; + private final EngineSoundSource source; + private final boolean rotor; + private float doppler; + private double lastDistance = -1.0; + + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.vehicle = vehicle; + this.source = source; + this.rotor = rotor; + this.looping = true; + this.delay = 0; + update(); + } + + @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 = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); + boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); + if (aboard) { + x = vehicle.getX(); + 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-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java new file mode 100644 index 00000000..62e52284 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -0,0 +1,75 @@ +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 and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. + */ +public final class EngineSoundPlayer { + /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ + private static final int RESTART_DELAY = 20; + + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); + + private EngineSoundPlayer() { + } + + 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; + } + AircraftDefinition def = vehicle.definition(); + if (def == null) { + continue; + } + double range = def.soundRange * 1.2; + if (vehicle.distanceToSqr(player) > range * range) { + continue; + } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); + } + } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 00000000..e24bf68d --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -0,0 +1,79 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +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; +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 = super.resolve(manager); + if (events != null) { + return events; + } + file = RuntimeAssets.soundFile(location.getPath()); + if (file == null) { + 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.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..26401727 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; + +/** + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, so the undo history and the view are + * as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + + public static void tick() { + tickSubmit(); + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.screen == null) { + mc.setScreen(screen); + } + } + } + + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + + public static void loggedOut() { + submitting = null; + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..cc44546a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,201 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.home.packs"), b -> minecraft.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().getName() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.drawString(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.drawString(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..b8abc314 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import net.minecraft.Util; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Util.getPlatform().openFile(folder.toFile()); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.setScreen(new WorkshopHomeScreen()); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + graphics.drawCenteredString(font, title, width / 2, 10, TEXT); + if (rows.isEmpty()) { + graphics.drawString(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphics graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphics graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.drawString(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..2f386337 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1670 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.AssetImport; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.blaze3d.platform.Lighting; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

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

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

Movement is simulated wherever {@link #isLocalInstanceAuthoritative()} 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 VehicleEntity { + 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); + private static final EntityDataAccessor DATA_FUEL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + /** The UAV station a drone is flown from, by entity id, or 0. */ + private static final EntityDataAccessor DATA_STATION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_REPELLING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SEARCH_LIGHT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + /** Marks a player flying a drone from a station, so a stale hide can be undone at login. */ + public static final String REMOTE_PILOT_TAG = "mcheli_remote_pilot"; + + // 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; + + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + 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<>(); + /** The station a drone is flown from, and where its pilot goes back to. Server only. */ + private @Nullable UUID station; + private @Nullable Vec3 stationReturn; + /** Remote pilots leaving this tick, by entity id, with where they come back down. */ + private final Map remoteReturns = new HashMap<>(); + /** Where a workshop test flight started, or {@code null} for an ordinary vehicle. Server only. */ + private @Nullable Vec3 testFlightStart; + private boolean testFlightOver; + /** How far the ropes hang below their hooks, as a negative number; both sides let them out. */ + private float ropeLength; + private int repellingTicks; + private int lastRope; + /** Passengers are leaving one by one under parachutes. Server only. */ + private boolean droppingTroops; + + 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; + /** The fuel load from which an aircraft counts as heavy: the H8K flying boat and up. */ + private static final int HEAVY_FUEL = 3000; + /** How long a wreck stays, burning, before it disappears: 30 seconds. */ + private static final int WRECK_TICKS = 600; + /** 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; + /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ + private int wreckTicks; + /** Ticks since this aircraft was shot down, for the crash spin. Counted on both sides. */ + private int spinTicks; + 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; + + /** Fuel burnt but not yet taken from the tank, which only holds whole units. Server only. */ + private double fuelBurnt; + /** Ticks a fuel truck nearby keeps this vehicle from burning fuel. Server only. */ + private int fuelSuppliedCount; + /** One bit per seat whose occupant has night vision on. Server only, not saved. */ + private int nightVisionSeats; + /** Fuel and parachute slots and the cargo chest. Only the server's copy holds anything. */ + private final VehicleInventory inventory = new VehicleInventory(this); + + // 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; + /** + * How far a hull reaches past its main box, at most, when looking for neighbours: other + * vehicles are found by their main box, and the longest hulls stretch about this far from it. + */ + private static final double HULL_REACH = 24.0; + /** 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) { + super.defineSynchedData(builder); + 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); + builder.define(DATA_FUEL, 0); + builder.define(DATA_STATION, 0); + builder.define(DATA_REPELLING, false); + builder.define(DATA_SEARCH_LIGHT, 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); + entityData.set(DATA_FUEL, definition.maxFuel); + 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); + } + + /** + * {@code repair}: adds hit points up to the maximum. A wreck stays a wreck. Server only. + * + * @return whether anything was repaired + */ + public boolean repair(int amount) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || hp() >= def.maxHp) { + return false; + } + entityData.set(DATA_HP, Math.min(hp() + amount, def.maxHp)); + return true; + } + + /** + * {@code switchNextTextureName}: the next paint scheme the definition lists. Server only. + * + * @return false when the vehicle has only one + */ + public boolean nextTexture() { + AircraftDefinition def = definition(); + if (def == null || def.textures().size() < 2) { + return false; + } + List textures = def.textures(); + int next = (textures.indexOf(texture()) + 1) % textures.size(); + entityData.set(DATA_TEXTURE, textures.get(next)); + return true; + } + + // Fuel + + public int fuel() { + return entityData.get(DATA_FUEL); + } + + public int maxFuel() { + AircraftDefinition def = definition(); + return def != null ? def.maxFuel : 0; + } + + /** {@code getFuelP}: how full the tank is, 0 to 1. Vehicles without a tank read as full. */ + public float fuelRatio() { + int max = maxFuel(); + return max <= 0 ? 1.0F : (float) fuel() / max; + } + + /** {@code canUseFuel}: a vehicle without a tank never runs dry. */ + public boolean hasFuel() { + return maxFuel() <= 0 || fuel() > 1; + } + + /** Sets the tank outright; a drone leaves its station with 5%. Server only. */ + public void setFuelLevel(int fuel) { + entityData.set(DATA_FUEL, Math.clamp(fuel, 0, Math.max(maxFuel(), 0))); + } + + /** + * Pours up to {@code amount} into the tank. Server only. + * + * @return how much went in + */ + public int refuel(int amount) { + int added = Math.clamp(maxFuel() - fuel(), 0, Math.max(amount, 0)); + if (added > 0) { + entityData.set(DATA_FUEL, fuel() + added); + } + return added; + } + + // Night vision + + /** A crew member switched night vision on or off. Server only. */ + public void setNightVision(Player player, boolean on) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (seat < 0 || seat >= 31) { + return; + } + if (on && def != null && def.nightVision && !isDestroyed()) { + nightVisionSeats |= 1 << seat; + } else if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(player); + } + } + + /** Keeps night vision on for everyone in the crew who switched it on, see {@link NightVision}. */ + private void tickNightVision() { + if (nightVisionSeats == 0) { + return; + } + if (isDestroyed()) { + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (crew != null) { + NightVision.end(crew); + } + } + nightVisionSeats = 0; + return; + } + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (hasNightVision(seat) && crew != null) { + NightVision.topUp(crew); + } + } + } + + /** Only the first 31 seats can switch it on; racks and the like come after them anyway. */ + private boolean hasNightVision(int seat) { + return seat >= 0 && seat < 31 && (nightVisionSeats & 1 << seat) != 0; + } + + /** + * {@code updateFuel}: once a second the engine burns its {@code FuelConsumption}, scaled by the + * throttle and topping out at 70% throttle. Nothing burns next to a fuel truck or with the + * server's infinite fuel on. + */ + private void burnFuel(AircraftDefinition def) { + if (def.maxFuel <= 0 || isDestroyed()) { + return; + } + if (fuelSuppliedCount > 0) { + fuelSuppliedCount--; + return; + } + if (ticksAlive % 20 != 0 || fuel() <= 1 || throttle <= 0.0 || ServerConfig.infiniteFuel()) { + return; + } + fuelBurnt += Math.min(throttle * 1.4, 1.0) * def.fuelConsumption; + if (fuelBurnt > 1.0) { + int whole = (int) fuelBurnt; + fuelBurnt -= whole; + entityData.set(DATA_FUEL, Math.max(fuel() - whole, 0)); + } + } + + /** + * {@code supplyFuel}: a fuel truck ({@code FuelSupplyRange}) tops up every vehicle within range, + * 30 units every half second. While it is on the ground it only reaches vehicles that are on + * the ground too. + */ + private void supplyFuel(AircraftDefinition def) { + float range = def.fuelSupplyRange; + if (range <= 0.0F || ticksAlive % 10 != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + if (other == this) { + continue; + } + if (!onGround() || other.onGround()) { + other.refuel(30); + } + other.fuelSuppliedCount = 40; + } + } + + // Inventory and supply + + public VehicleInventory inventory() { + return inventory; + } + + /** + * {@code canSupply}: fuel cans and resupply only work while the vehicle stands on something, + * within three blocks; a vehicle that cannot float must not be in the water either. + */ + public boolean canSupply() { + AircraftDefinition def = definition(); + return def != null && hasBlockBelow(1, 3, false) && (def.floats || !isInWater()); + } + + /** {@code openGui}: shows a crew member the vehicle screen. Server only. */ + public void openScreen(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || player.getVehicle() != this) { + return; + } + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new VehicleMenu(id, playerInventory, this), displayName(def))); + } + + /** + * {@code openInventory}: shows the cargo chest of a vehicle with an {@code InventorySize}. + * Server only. + * + * @return false when the vehicle has none + */ + public boolean openCargo(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || def.cargoRows() <= 0 || isDestroyed() || !inventory.stillValid(player)) { + return false; + } + int rows = Math.min(def.cargoRows(), 6); + MenuType type = switch (rows) { + case 1 -> MenuType.GENERIC_9x1; + case 2 -> MenuType.GENERIC_9x2; + case 3 -> MenuType.GENERIC_9x3; + case 4 -> MenuType.GENERIC_9x4; + case 5 -> MenuType.GENERIC_9x5; + default -> MenuType.GENERIC_9x6; + }; + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new ChestMenu(type, id, playerInventory, inventory.cargo(), rows), + displayName(def))); + return true; + } + + /** The vehicle's name as its item shows it. */ + private static Component displayName(AircraftDefinition def) { + return Component.translatableWithFallback("item.mcheli." + def.name(), def.displayName); + } + + /** + * {@code supplyAmmoToOtherAircraft}: an ammunition truck ({@code AmmoSupplyRange}) tops up the + * weapons of every vehicle within range that stands on something, a tenth of each weapon's + * maximum every two seconds. + */ + private void supplyAmmo(AircraftDefinition def) { + float range = def.ammoSupplyRange; + if (range <= 0.0F || ticksAlive % SupplyRules.AMMO_TRUCK_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + VehicleWeapons theirs = other.weapons(); + if (other == this || theirs == null || other.isDestroyed() || !other.canSupply()) { + continue; + } + for (WeaponSet set : theirs.sets()) { + int rounds = SupplyRules.roundsFromTruck(set.ammo() + set.restAmmo(), set.maxAmmo()); + if (rounds > 0) { + set.setRestAmmo(set.restAmmo() + rounds); + if (set.ammo() <= 0) { + set.reloadMagazine(); + } + } + } + } + } + + /** {@code repairOtherAircraft}: a repair vehicle ({@code RepairOtherVehicles}) mends those in range. */ + private void repairOthers(AircraftDefinition def) { + float range = def.repairOtherVehiclesRange; + if (range <= 0.0F || ticksAlive % SupplyRules.REPAIR_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + AircraftDefinition theirs = other.definition(); + if (other != this && theirs != null) { + other.repair(SupplyRules.repair(other.hp(), theirs.maxHp, def.repairOtherVehiclesValue)); + } + } + } + + /** 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 isLocalInstanceAuthoritative() && !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 + + /** + * Makes this a workshop test flight: it takes no damage, is not saved with the world, and goes + * away once its pilot gets out, putting them back where they started. + */ + public void startTestFlight(Vec3 start) { + testFlightStart = start; + } + + @Override + public boolean shouldBeSaved() { + return testFlightStart == null && super.shouldBeSaved(); + } + + @Override + public void tick() { + if (testFlightOver) { + discard(); + return; + } + rollO = roll(); + if (getHurtTime() > 0) { + setHurtTime(getHurtTime() - 1); + } + super.tick(); + interpolation.interpolate(); + ticksAlive++; + lastSeats.clear(); + remoteReturns.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 (!level().isClientSide()) { + supplyFuel(def); + supplyAmmo(def); + repairOthers(def); + burnFuel(def); + if (def.maxFuel > 0 && fuel() < def.maxFuel && !isDestroyed()) { + inventory.pourFuelCans(ticksAlive); + } + tickNightVision(); + tickStation(); + } + tickRopes(def); + + if (isSimulatingHere()) { + if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { + input.clear(); + } + if (isRepelling()) { + // {@code MCH_AircraftClientTickHandler}: the pilot holds a hover while the ropes are out. + input.throttleUp = false; + input.throttleDown = false; + input.left = false; + input.right = false; + } + if (!hasFuel()) { + // An empty tank: the throttle runs down by itself, as with the pilot's hand off it. + input.throttleUp = false; + } + 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 (!isLocalInstanceAuthoritative()) { + 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); + VehicleParticles.tick(this, 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) { + hurtServer((ServerLevel) level(), 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)) { + hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), crashDamage(previous.horizontalDistance())); + } + if (speed > VEHICLE_CRASH_SPEED && collisionCooldown == 0) { + // A blocked move can leave the hulls up to a tick of travel apart; when the vehicle + // stopped short this tick, look that far for what stopped it. + boolean stopped = velocity.length() < previous.length() * 0.5; + double reach = stopped ? 0.5 + previous.length() : 0.5; + for (AircraftEntity other : solidNeighbours(hullBounds().inflate(reach))) { + if (!hullsTouch(other, reach)) { + continue; + } + 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); + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), damage); + other.hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), 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) { + hurtServer((ServerLevel) level(), 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() || current == 0 && station != null) { + // A remote pilot stays at the controls; they are not really aboard. + 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); + } + + // UAV stations + + /** Whether a player at a station could take this drone's controls now. */ + public boolean canTakeRemotePilot() { + AircraftDefinition def = definition(); + return def != null && def.uav && !def.targetDrone && !isDestroyed() && isSeatFree(0); + } + + /** + * Seats {@code player}, who sits at {@code from}, at this drone's controls. The pilot is hidden + * and takes no harm while flying, since in the original they never left the station. Server only. + */ + public boolean takeRemotePilot(ServerPlayer player, UavStationEntity from) { + if (!canTakeRemotePilot()) { + return false; + } + pendingSeat = 0; + boolean seated = player.startRiding(this, true); + pendingSeat = EMPTY; + if (!seated) { + return false; + } + station = from.getUUID(); + stationReturn = from.returnPosition(); + entityData.set(DATA_STATION, from.getId()); + player.addTag(REMOTE_PILOT_TAG); + player.setInvisible(true); + player.setInvulnerable(true); + return true; + } + + /** Sends the remote pilot back to the station, if one is flying. Server only. */ + public void releaseRemotePilot() { + Entity pilot = occupant(0); + if (station != null && pilot != null) { + pilot.stopRiding(); + } + } + + /** The station this drone is flown from, as the client sees it. */ + public @Nullable UavStationEntity stationEntity() { + return level().getEntity(entityData.get(DATA_STATION)) instanceof UavStationEntity s ? s : null; + } + + /** Flat distance to the station this drone is flown from, or -1 without one. */ + public double stationDistance() { + UavStationEntity from = stationEntity(); + if (from == null) { + return -1.0; + } + double dx = getX() - from.getX(); + double dz = getZ() - from.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } + + /** Whether {@code entity} flies this drone from a station rather than sitting in it. */ + public boolean isRemotePilot(Entity entity) { + return (station != null || entityData.get(DATA_STATION) != 0) && occupant(0) == entity; + } + + private void endRemoteControl(Entity pilot) { + UavStationEntity from = level() instanceof ServerLevel server && station != null + && server.getEntity(station) instanceof UavStationEntity s ? s : null; + Vec3 home = from != null ? from.returnPosition() : stationReturn; + if (home != null) { + remoteReturns.put(pilot.getId(), home); + } + clearRemotePilot(pilot); + if (from != null) { + from.setLinked(false); + } + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + } + + /** Undoes what {@link #takeRemotePilot} did to the player. */ + public static void clearRemotePilot(Entity pilot) { + if (pilot.removeTag(REMOTE_PILOT_TAG)) { + pilot.setInvisible(false); + pilot.setInvulnerable(false); + } + } + + /** + * {@code updateUAV}: the link breaks when the station is gone, and flying out of its range + * loses the drone, as in the original. + */ + private void tickStation() { + if (station == null || !(level() instanceof ServerLevel server)) { + return; + } + Entity pilot = occupant(0); + if (pilot == null) { + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + return; + } + UavStationEntity from = server.getEntity(station) instanceof UavStationEntity s && s.isAlive() ? s : null; + if (from == null) { + pilot.stopRiding(); + return; + } + entityData.set(DATA_STATION, from.getId()); + if (!from.inRange(this) && !isDestroyed()) { + if (pilot instanceof Player player) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.lost"), true); + } + pilot.stopRiding(); + hurtServer(server, damageSources().fellOutOfWorld(), hp() + 10.0F); + } + } + + @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); + } + // Coming back aboard after a reload, a remote pilot included. + Integer saved = savedSeats.get(passenger.getUUID()); + if (saved != null && isSeatFree(saved)) { + return true; + } + // Cargo only boards through a rack, never into a crew seat. + return !(passenger instanceof AircraftEntity || passenger instanceof ContainerEntity) && 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) { + if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(passenger); + } + lastSeats.put(passenger.getId(), seat); + seatIds[seat] = EMPTY; + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onLeft(seat); + } + } + if (seat == 0) { + input.clear(); + if (station != null) { + endRemoteControl(passenger); + } + if (testFlightStart != null) { + remoteReturns.put(passenger.getId(), testFlightStart); + testFlightOver = true; + } + } + } + + @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); + } else if (def != null && passenger instanceof ContainerEntity container) { + RackDefinition rack = def.rack(seatOf(passenger)); + container.setYRot(getYRot() + (rack != null ? rack.yaw() : 0.0F)); + container.setDeltaMovement(Vec3.ZERO); + } + } + + @Override + public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + Vec3 home = remoteReturns.remove(passenger.getId()); + if (home != null) { + return home; + } + 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); + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + ItemStack held = player.getItemInHand(hand); + if (held.getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (definition() != null && (held.getItem() instanceof FuelItem || held.getItem() instanceof WrenchItem)) { + if (level().isClientSide()) { + return InteractionResult.SUCCESS; + } + return held.getItem() instanceof FuelItem ? FuelItem.pour(held, this, player) : WrenchItem.useOn(held, this, player, hand); + } + InteractionResult result = super.interact(player, hand); + AircraftDefinition def = definition(); + if (result == InteractionResult.PASS && player.isSecondaryUseActive() && def != null && def.cargoRows() > 0 + && !isDestroyed() && player.getVehicle() != this) { + // Sneaking opens the cargo chest from outside, as in the original. + if (player instanceof ServerPlayer server) { + openCargo(server); + } + return InteractionResult.SUCCESS; + } + if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { + return result; + } + 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(); + boardNearbyMobs(); + } + } + return InteractionResult.SUCCESS; + } + + // Racks + + /** Loads {@code cargo} into the given rack seat. Server only. */ + private boolean loadCargo(Entity 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; + } + + /** Whether rack {@code seat} carries containers, {@code AddRack} naming {@code container}. */ + public boolean acceptsContainer(ContainerEntity container, int seat) { + AircraftDefinition def = definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + return rack != null && !isPassenger() && !container.isPassenger() + && rack.accepts(ContainerEntity.CARGO_NAME, ContainerEntity.CARGO_NAME); + } + + 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 or container 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()); + boolean loaded = false; + for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { + if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + loaded = true; + break; + } + } + if (!loaded) { + for (ContainerEntity container : level().getEntitiesOfClass(ContainerEntity.class, area)) { + if (acceptsContainer(container, seat) && loadCargo(container, 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.snapTo(at.x, at.y, at.z, getYRot() + rack.yaw(), 0.0F); + // As in the original, with no ground in the 20 blocks below it goes down under a canopy. + if (!(cargo instanceof AircraftEntity) && !hasBlockBelow(3, 20, false)) { + ParachuteEntity.open(level(), cargo, ParachuteEntity.Kind.DROP); + } + 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(); + snapTo(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); + } + + /** + * {@code ejectSeat}: throws the occupant clear of the aircraft in an ejection seat, whose + * parachute opens once it starts to fall. Each ejection takes a parachute from the parachute + * slots of the vehicle screen; without one the seat stays put. Creative players need none. + * Server only. + */ + public void eject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + if (def == null || !canEject(entity)) { + return; + } + boolean creative = entity instanceof Player player && player.getAbilities().instabuild; + if (!creative && !inventory.takeParachute()) { + if (entity instanceof ServerPlayer player) { + ModSounds.playTo(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.no_parachute"), true); + } + 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.hasImpulse = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.EJECTION_SEAT); + if (canopy != null && !canopy.status()) { + canopy.set(true, false); + modeSwitchCooldown = 40; + } + ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); + } + + // Search lights + + public boolean isSearchLightOn() { + AircraftDefinition def = definition(); + return def != null && !def.searchLights.isEmpty() && entityData.get(DATA_SEARCH_LIGHT); + } + + /** {@code canSwitchSearchLight}: the pilot or the first crew seat, in vehicles that have one. */ + public boolean canSwitchSearchLight(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && !def.searchLights.isEmpty() && seat >= 0 && seat <= 1; + } + + /** Server only. */ + public void toggleSearchLight(Entity entity) { + if (canSwitchSearchLight(entity)) { + entityData.set(DATA_SEARCH_LIGHT, !entityData.get(DATA_SEARCH_LIGHT)); + } + } + + /** + * Where a turning search light points: the first crew seat's view, else the pilot's, as + * {@code renderLight} took them. Null with nobody aboard to aim it. + */ + public @Nullable Entity searchLightAimer() { + Entity crew = occupant(1); + return crew != null ? crew : occupant(0); + } + + // Troops: parachute drops and ropes + + public boolean isRepelling() { + return entityData.get(DATA_REPELLING); + } + + /** How far the ropes hang below their hooks, as a negative number. */ + public float ropeLength() { + return ropeLength; + } + + /** Where rope {@code index} hangs from, in the world. */ + public Vec3 ropePosition(int index) { + AircraftDefinition def = definition(); + return def != null && index >= 0 && index < def.repellingHooks.size() ? toWorld(def.repellingHooks.get(index).position()) : position(); + } + + /** + * {@code onUpdate_Repelling} and {@code onUpdate_UnmountCrew}: the ropes run out towards the + * ground, then the mobs aboard slide down them one per hook interval; a parachute drop lets one + * passenger out per {@code MobDropOption} interval while the hatch is open. + */ + private void tickRopes(AircraftDefinition def) { + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + int clear = airBelow(50); + if (ropeLength > -50.0F && ropeLength > -clear) { + ropeLength -= level().isClientSide() ? 0.3F : 0.25F; + } + } else { + ropeLength = 0.0F; + } + } + if (level().isClientSide()) { + return; + } + if (isRepelling() && isDestroyed()) { + entityData.set(DATA_REPELLING, false); + } + if (!isRepelling()) { + repellingTicks = 0; + } else if (repellingTicks < 60) { + repellingTicks++; + } else { + for (int rope = 0; rope < def.repellingHooks.size(); rope++) { + if (ticksAlive % def.repellingHooks.get(rope).interval() != 0) { + continue; + } + for (int seat = 1; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + rappel(mob, rope); + break; + } + } + } + } + if (droppingTroops) { + if (hasBlockBelow(3, 10, false) || isDestroyed()) { + droppingTroops = false; + } else if ((hatch == null || hatch.isOn()) && ticksAlive % def.dropInterval == 0 && !dropNextTroop(def)) { + droppingTroops = false; + } + } + } + + /** Air blocks straight below, up to {@code max}. */ + private int airBelow(int max) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + int i = 0; + while (i < max && pos.getY() - i > level().getMinY() && level().getBlockState(pos.setY(blockPosition().getY() - i - 1)).isAir()) { + i++; + } + return i; + } + + /** + * {@code canStartRepelling}: a helicopter in hover mode, level and holding still. The original + * compared against where it was a few ticks before. + */ + private boolean canStartRepelling() { + return this instanceof HelicopterEntity && isHoveringMode() && Math.abs(getXRot()) < 3.0F && Math.abs(roll()) < 3.0F + && lastTickPos != null && position().distanceTo(lastTickPos) < 0.05; + } + + /** + * The pilot's drop key, {@code unmountCrew}: with ropes, lets them down from a steady hover + * or pulls them up again; with {@code EnableParachuting}, starts or stops dropping the mobs + * aboard under parachutes; otherwise, or close to the ground, simply lets the mobs out. + * Server only. + * + * @return false if nothing could be done, so the pilot hears it + */ + public boolean dropCrew() { + AircraftDefinition def = definition(); + if (def == null || isDestroyed()) { + return false; + } + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + entityData.set(DATA_REPELLING, false); + return true; + } + if (hasBlockBelow(3, 4, false)) { + return unloadMobs(); + } + if (!canStartRepelling()) { + return false; + } + entityData.set(DATA_REPELLING, true); + repellingTicks = 0; + return true; + } + if (droppingTroops) { + droppingTroops = false; + return true; + } + if (def.parachuting && !hasBlockBelow(3, 10, false)) { + droppingTroops = true; + if (hatch != null && hatch.isOff()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } + return true; + } + return unloadMobs(); + } + + /** Lets every mob in a crew seat out, as in {@code unmountCrew(false)}. */ + private boolean unloadMobs() { + AircraftDefinition def = definition(); + boolean any = false; + for (int seat = 1; def != null && seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + mob.stopRiding(); + any = true; + } + } + return any; + } + + /** The next mob behind the second seat leaves at the drop point under a parachute. */ + private boolean dropNextTroop(AircraftDefinition def) { + for (int seat = 2; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + parachuteOut(mob, def); + return true; + } + } + return false; + } + + private void parachuteOut(Entity entity, AircraftDefinition def) { + Vec3 out = toWorld(def.dropPosition); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement()); + entity.hasImpulse = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.DROP); + } + + /** + * A passenger's own drop key, {@code MCH_ClientSeatTickHandler}: slide down a rope once the + * ropes are out, or jump with a parachute from behind the second seat of a vehicle with + * {@code EnableParachuting} and room below. Server only. + * + * @return false if neither is possible right now + */ + public boolean jumpOut(Player player) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (def == null || seat <= 0 || seat >= def.seatCount() || isDestroyed()) { + return false; + } + if (isRepelling() && repellingTicks > 50 && !def.repellingHooks.isEmpty()) { + lastRope = (lastRope + 1) % def.repellingHooks.size(); + rappel(player, lastRope); + return true; + } + if (def.parachuting && seat >= 2 && !hasBlockBelow(3, 13, false)) { + parachuteOut(player, def); + return true; + } + return false; + } + + /** {@code unmountEntityRepelling}: two blocks under the hook, onto a rope rider that lets them down. */ + private void rappel(Entity entity, int rope) { + Vec3 at = ropePosition(rope).add(0.0, -2.0, 0.0); + entity.stopRiding(); + entity.teleportTo(at.x, at.y, at.z); + entity.setDeltaMovement(Vec3.ZERO); + entity.resetFallDistance(); + RappelEntity rider = new RappelEntity(ModEntities.RAPPEL.get(), level()); + rider.attach(this, rope); + rider.setPos(at); + level().addFreshEntity(rider); + entity.startRiding(rider, true); + lastRope = rope; + } + + /** + * {@code mountMobToSeats}: when the pilot climbs in, mobs standing close by take the free crew + * seats, so villagers and the like can be flown somewhere. Server only. + */ + private void boardNearbyMobs() { + AircraftDefinition def = definition(); + if (def == null || def.uav) { + return; + } + for (Mob mob : level().getEntitiesOfClass(Mob.class, getBoundingBox().inflate(3.0, 2.0, 3.0), m -> !m.isPassenger() && !m.isVehicle())) { + for (int seat = 1; seat < def.seatCount(); seat++) { + if (isSeatFree(seat)) { + pendingSeat = seat; + mob.startRiding(this, true); + pendingSeat = EMPTY; + break; + } + } + } + } + + // 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.hull()) { + Optional hit = clip(hullBox(box).inflate(inflate), from, to); + if (hit.isPresent() && from.distanceToSqr(hit.get()) < bestDistance) { + best = hit; + bestDistance = from.distanceToSqr(hit.get()); + hitBoxFactor = box.damageFactor(); + } + } + } + return best; + } + + /** + * One {@code BoundingBox} in world space. Like the original's, it stays axis aligned: the + * centre turns with the hull, the box itself does not. + */ + private AABB hullBox(BoundingBoxDefinition box) { + Vec3 c = toWorld(box.offset()); + double hw = box.width() / 2.0; + double hh = box.height() / 2.0; + return new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw); + } + + /** The whole hull: the main hit box, every {@code BoundingBox} and the generated boxes, in world space. */ + public List hullBoxes() { + AircraftDefinition def = definition(); + if (def == null || def.hull().isEmpty()) { + return List.of(getBoundingBox()); + } + List boxes = new ArrayList<>(def.hull().size() + 1); + boxes.add(getBoundingBox()); + for (BoundingBoxDefinition box : def.hull()) { + boxes.add(hullBox(box)); + } + return boxes; + } + + /** The smallest box around the whole hull. */ + private AABB hullBounds() { + AABB bounds = getBoundingBox(); + AircraftDefinition def = definition(); + if (def != null) { + for (BoundingBoxDefinition box : def.hull()) { + bounds = bounds.minmax(hullBox(box)); + } + } + return bounds; + } + + /** Whether any part of this hull touches any part of {@code other}'s, grown by {@code inflate}. */ + private boolean hullsTouch(AircraftEntity other, double inflate) { + // Hulls generated from big models run to a thousand boxes, so skip ours that are nowhere near. + AABB around = other.hullBounds().inflate(inflate); + List theirs = null; + for (AABB mine : hullBoxes()) { + AABB grown = mine.inflate(inflate); + if (!grown.intersects(around)) { + continue; + } + if (theirs == null) { + theirs = other.hullBoxes(); + } + for (AABB box : theirs) { + if (grown.intersects(box)) { + return true; + } + } + } + return false; + } + + /** + * Vehicles whose hulls block this one: everything nearby except its own carrier or cargo, and + * vehicles either of the two could load onto a rack, which have to drive into each other. + */ + private List solidNeighbours(AABB area) { + return level().getEntitiesOfClass(AircraftEntity.class, area.inflate(HULL_REACH), other -> other != this && !other.isRemoved() + && other.definition() != null && !other.isPassenger() && !isPassengerOfSameVehicle(other) + && other.getVehicle() != this && getVehicle() != other && !canCarry(other) && !other.canCarry(this)); + } + + /** Whether a free rack of this vehicle would take {@code cargo}. */ + private boolean canCarry(AircraftEntity cargo) { + AircraftDefinition def = definition(); + if (def == null) { + return false; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (acceptsCargo(cargo, seat)) { + return true; + } + } + return false; + } + + /** + * Not in the original, where only the main box at a vehicle's feet collided: the whole hull + * blocks other vehicles. A move that would push this hull into another vehicle's is undone, + * sideways first and then completely, so a helicopter can also set down on a parked truck. + * Hulls that already overlapped, say after two vehicles were placed into each other, may move + * apart. + */ + @Override + public void move(MoverType type, Vec3 delta) { + if (type != MoverType.SELF || isPassenger() || definition() == null) { + super.move(type, delta); + return; + } + List near = solidNeighbours(hullBounds().expandTowards(delta)); + if (near.isEmpty()) { + super.move(type, delta); + return; + } + List overlapping = new ArrayList<>(); + for (AircraftEntity other : near) { + if (hullsTouch(other, 0.0)) { + overlapping.add(other); + } + } + Vec3 before = position(); + super.move(type, delta); + if (!entersHull(near, overlapping)) { + return; + } + Vec3 m = getDeltaMovement(); + setPos(before.x, getY(), before.z); + setDeltaMovement(0.0, m.y, 0.0); + if (entersHull(near, overlapping)) { + setPos(before); + setDeltaMovement(Vec3.ZERO); + } + } + + private boolean entersHull(List near, List overlapping) { + for (AircraftEntity other : near) { + if (!overlapping.contains(other) && hullsTouch(other, 0.0)) { + return true; + } + } + return false; + } + + /** 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + float boxFactor = consumeHitBoxFactor(); + if (testFlightStart != null || isRemoved() || isInvulnerableToBase(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() || isHeavy() && isNearGround(3)) { + 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(level, 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; + 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; + } + if (isHeavy()) { + startCookOff(null); + return; + } + exploded = true; + AircraftDefinition def = definition(); + float size = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + Vec3 at = def != null && def.fuelTankPosition != null ? toWorld(def.fuelTankPosition) : position(); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, null, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + if (!isInWater()) { + Explosions.crashFireball(level, at.x, at.y, at.z, size); + } + } + + /** + * Airliners, bombers and transports ({@code MaxFuel} of {@value HEAVY_FUEL} or more): like a + * boat, they go up in three blasts rather than one fireball. + */ + private boolean isHeavy() { + AircraftDefinition def = definition(); + return def != null && def.maxFuel >= HEAVY_FUEL; + } + + /** 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, or a heavy aircraft wrecked on the ground, does not go up in + * one crash fireball; its fuel and magazine cook off in three blasts over about five seconds, + * then a hull in the water 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 { + // A boat or a flying boat goes under; a heavy aircraft on land stays where it burns. + if (isInWater() || waterDepth() > 0.0) { + 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; + crashSpin(); + 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(); + } + } + + /** + * Not in the original: a shot down plane rolls into a nose-down spiral and a helicopter spins + * with some wobble, until it is close to the ground. Tanks and cars just stop. + */ + private void crashSpin() { + if (!(this instanceof HelicopterEntity) && !(this instanceof PlaneEntity) || isNearGround(3) || isInWater()) { + return; + } + int direction = CrashSpin.direction(getId()); + int ticks = spinTicks++; + if (this instanceof HelicopterEntity) { + setYRot(getYRot() + CrashSpin.helicopterYawRate(ticks, direction)); + setXRot(Mth.approach(getXRot(), CrashSpin.helicopterWobblePitch(ticks, getId()), 2.0F)); + roll = Mth.approach(roll, CrashSpin.helicopterWobbleRoll(ticks, getId()), 3.0F); + } else { + setYRot(getYRot() + CrashSpin.planeYawRate(ticks, direction)); + setXRot(CrashSpin.planePitch(getXRot())); + roll = AircraftRotation.wrap(roll + CrashSpin.planeRollRate(ticks, direction)); + } + } + + /** {@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 = def.enginePosition != null ? toWorld(def.enginePosition) : 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); + } + } + + /** A vehicle that is destroyed, packed up or killed spills what it carried; one that unloads keeps it. */ + @Override + public void remove(RemovalReason reason) { + if (reason.shouldDestroy() && !level().isClientSide() && !isRemoved()) { + inventory.dropAll(); + } + super.remove(reason); + } + + @Override + protected Item getDropItem() { + AircraftDefinition def = definition(); + return def != null ? ModItems.forKind(def.kind()) : Items.AIR; + } + + @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()); + } + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putString("Type", typeName()); + output.putString("Texture", texture()); + output.putInt("Hp", hp()); + output.putInt("Fuel", fuel()); + output.putFloat("Roll", roll); + output.putBoolean("Hovering", isHoveringMode()); + output.putBoolean("GunnerMode", isGunnerMode()); + output.putInt("Parts", partBits()); + output.putInt("DespawnCount", despawnCount); + output.putInt("CookOff", cookOffBlasts); + output.putBoolean("Sinking", entityData.get(DATA_SINKING)); + output.putBoolean("SearchLight", entityData.get(DATA_SEARCH_LIGHT)); + if (weapons != null) { + output.putIntArray("WeaponsAmmo", weapons.saveAmmo()); + } + inventory.save(output, registryAccess()); + if (station != null && stationReturn != null) { + output.store("Station", UUIDUtil.CODEC, station); + output.store("StationReturn", Vec3.CODEC, stationReturn); + } + List seats = new ArrayList<>(); + for (int i = 0; i < seatIds.length; i++) { + Entity e = occupant(i); + seats.add(e != null ? e.getUUID().toString() : ""); + } + output.store("Seats", Codec.STRING.listOf(), seats); + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + entityData.set(DATA_TYPE, input.getStringOr("Type", "")); + entityData.set(DATA_TEXTURE, input.getStringOr("Texture", "")); + entityData.set(DATA_PARTS, input.getIntOr("Parts", 0)); + AircraftDefinition def = definition(); + entityData.set(DATA_HP, input.getIntOr("Hp", def != null ? def.maxHp : 1)); + // 1.0.0 and the earlier betas had no fuel; their vehicles come back with a full tank. + entityData.set(DATA_FUEL, input.getIntOr("Fuel", def != null ? def.maxFuel : 0)); + roll = input.getFloatOr("Roll", 0.0F); + setHovering(input.getBooleanOr("Hovering", false)); + entityData.set(DATA_GUNNER_MODE, input.getBooleanOr("GunnerMode", false)); + despawnCount = input.getIntOr("DespawnCount", 0); + if (hp() <= 0 && despawnCount <= 0) { + despawnCount = WRECK_TICKS; + } + cookOffBlasts = input.getIntOr("CookOff", 0); + cookOffDelay = cookOffBlasts > 0 ? 20 : 0; + entityData.set(DATA_SINKING, input.getBooleanOr("Sinking", false)); + entityData.set(DATA_SEARCH_LIGHT, input.getBooleanOr("SearchLight", false)); + exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); + input.getIntArray("WeaponsAmmo").ifPresent(ammo -> { + if (weapons != null) { + weapons.loadAmmo(ammo); + } else { + savedAmmo = ammo; + } + }); + inventory.load(input, registryAccess()); + station = input.read("Station", UUIDUtil.CODEC).orElse(null); + stationReturn = input.read("StationReturn", Vec3.CODEC).orElse(null); + savedSeats.clear(); + List seats = input.read("Seats", Codec.STRING.listOf()).orElse(List.of()); + for (int i = 0; i < seats.size(); i++) { + if (!seats.get(i).isEmpty()) { + try { + savedSeats.put(UUID.fromString(seats.get(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; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java new file mode 100644 index 00000000..eda5ed7b --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java new file mode 100644 index 00000000..4d18cd9a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java @@ -0,0 +1,192 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.UUIDUtil; +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.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 net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * A tow chain, {@code MCH_EntityChain}: it hangs above the vehicle that pulls and drags the towed + * one along, letting it drift up to the chain's length away and never more than two blocks past + * that. It goes when either end does. + */ +public class ChainEntity extends Entity { + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 15; + /** Ticks a chain loaded from disk waits for both ends to load before it gives up. */ + private static final int SEARCH_TICKS = 50; + + private static final EntityDataAccessor DATA_TOW = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TOWED = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + + private @Nullable UUID towId; + private @Nullable UUID towedId; + private @Nullable Entity tow; + private @Nullable Entity towed; + private int length = 4; + private int searchTicks = SEARCH_TICKS; + + public ChainEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + /** Chains {@code towed} to {@code tow}, at the distance they are apart now. Server only. */ + public static ChainEntity connect(Level level, Entity towed, Entity tow) { + ChainEntity chain = new ChainEntity(ModEntities.CHAIN.get(), level); + chain.setPos(tow.position().add(towed.position()).scale(0.5)); + chain.length = Mth.clamp((int) towed.distanceTo(tow), MIN_LENGTH, MAX_LENGTH); + chain.attach(towed, tow); + level.addFreshEntity(chain); + ModSounds.playAt(chain.tow, "chain_ct", 1.0F, 1.0F); + return chain; + } + + /** Whether {@code entity} can pull the other end: only vehicles can. */ + public static boolean canTow(Entity entity) { + return entity instanceof AircraftEntity; + } + + /** + * Only a vehicle pulls. Hooked the other way round, a container would hold a helicopter down, + * so the ends swap whichever order they were clicked in (and chains saved that way are fixed + * when they load). + */ + private void attach(Entity towed, Entity tow) { + if (!canTow(tow) && canTow(towed)) { + Entity swap = tow; + tow = towed; + towed = swap; + } + this.towed = towed; + this.tow = tow; + this.towedId = towed.getUUID(); + this.towId = tow.getUUID(); + entityData.set(DATA_TOWED, towed.getId()); + entityData.set(DATA_TOW, tow.getId()); + searchTicks = 0; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_TOW, -1); + builder.define(DATA_TOWED, -1); + } + + /** The end that pulls; on a client, as far as it has loaded. */ + public @Nullable Entity tow() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOW)) : tow; + } + + /** The end that is pulled. */ + public @Nullable Entity towed() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOWED)) : towed; + } + + /** Whether {@code entity} is either end of this chain. */ + public boolean holds(Entity entity) { + return entity == tow() || entity == towed(); + } + + /** Takes the chain off, with the sound of it coming loose. Server only. */ + public void disconnect() { + ModSounds.playAt(this, "chain_ct", 1.0F, 1.0F); + discard(); + } + + /** The chain on {@code entity}, at either end, if there is one within reach. */ + public static @Nullable ChainEntity on(Entity entity) { + for (ChainEntity chain : entity.level().getEntitiesOfClass(ChainEntity.class, entity.getBoundingBox().inflate(25.0))) { + if (chain.holds(entity)) { + return chain; + } + } + return null; + } + + @Override + public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel server)) { + return; + } + if (searchTicks > 0) { + searchTicks--; + Entity foundTowed = towedId != null ? server.getEntity(towedId) : null; + Entity foundTow = towId != null ? server.getEntity(towId) : null; + if (foundTowed != null && foundTow != null) { + attach(foundTowed, foundTow); + } + return; + } + if (tow == null || towed == null || tow.isRemoved() || towed.isRemoved() || tow == towed) { + disconnect(); + return; + } + towed.resetFallDistance(); + setPos(tow.getX(), tow.getY() + 2.0, tow.getZ()); + pull(tow, towed); + } + + /** {@code updateTowingEntityPosRot}: turns the towed end to face along the chain and drags it in. */ + private void pull(Entity tow, Entity towed) { + Vec3 d = towed.position().subtract(tow.position()); + double dist = d.length(); + if (dist <= length) { + return; + } + towed.setYRot((float) Math.toDegrees(Math.atan2(d.z, d.x)) + 90.0F); + double slack = dist - length; + towed.setDeltaMovement(towed.getDeltaMovement().subtract(d.scale(0.001 / slack))); + double max = length + 2.0; + if (dist > max) { + Vec3 at = tow.position().add(d.scale(max / dist)); + towed.setPos(at.x, at.y, at.z); + } + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + towId = input.read("Tow", UUIDUtil.CODEC).orElse(null); + towedId = input.read("Towed", UUIDUtil.CODEC).orElse(null); + length = Mth.clamp(input.getIntOr("Length", 4), MIN_LENGTH, MAX_LENGTH); + searchTicks = SEARCH_TICKS; + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + if (towId != null && towedId != null) { + output.store("Tow", UUIDUtil.CODEC, towId); + output.store("Towed", UUIDUtil.CODEC, towedId); + } + output.putInt("Length", length); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java new file mode 100644 index 00000000..28f14851 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +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) { + } + + @Override + public void onPackSound(PackSoundPacket 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); + + void onPackSound(PackSoundPacket packet); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java new file mode 100644 index 00000000..9310e332 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java @@ -0,0 +1,151 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +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.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A cargo container, {@code MCH_EntityContainer}: a crate with a large chest's worth of room that + * sits on the ground, floats, can be pushed around, and rides the racks of the vehicles that carry + * {@code container}. Dropped from a rack high enough it comes down under a parachute. + */ +public class ContainerEntity extends Entity { + /** What racks call it in their {@code AddRack} lines. */ + public static final String CARGO_NAME = "container"; + private static final int SLOTS = 54; + /** Punches add up; the original broke it past 40 with 20 per point of damage. */ + private static final int BREAK_DAMAGE = 40; + + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + private final SimpleContainer items = new SimpleContainer(SLOTS); + private int damageTaken; + + public ContainerEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide() || isPassenger()) { + return; + } + if (damageTaken > 0) { + damageTaken--; + } + Vec3 m = getDeltaMovement(); + double my = m.y; + if (isInWater() || isInLava()) { + // Bobs up to the surface rather than sinking, as the original's boat physics did. + my = (my < 0.0 ? my / 2.0 : my) + (isEyeInFluid(FluidTags.WATER) ? 0.04 : 0.007); + } else { + my -= 0.04; + } + double mx = m.x; + double mz = m.z; + if (onGround()) { + mx *= 0.9; + mz *= 0.9; + } + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + if (player.getItemInHand(hand).getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (player.isSecondaryUseActive()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider((id, inventory, p) -> ChestMenu.sixRows(id, inventory, items), + Component.translatable("entity.mcheli.container"))); + } + return InteractionResult.SUCCESS; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || !(source.getEntity() instanceof Player player)) { + // Only players take it apart; explosions and stray rounds leave the cargo alone. + return false; + } + ModSounds.playAt(this, "hit", 1.0F, 1.3F); + damageTaken += (int) (damage * 20.0F); + boolean creative = player.getAbilities().instabuild; + if (creative || damageTaken > BREAK_DAMAGE) { + if (!creative) { + spawnAtLocation(level, ModItems.CONTAINER.get()); + } + Containers.dropContents(level, this, items); + discard(); + } + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean isPushable() { + return !isPassenger(); + } + + @Override + public boolean canBeCollidedWith() { + return !isPassenger(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.CONTAINER.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + items.clearContent(); + ContainerHelper.loadAllItems(input, items.getItems(), registryAccess()); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + ContainerHelper.saveAllItems(output, items.getItems(), false, registryAccess()); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java new file mode 100644 index 00000000..6b25a388 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java new file mode 100644 index 00000000..d3e058a8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java @@ -0,0 +1,212 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.weapon.CasStrike; +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.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The ground laser target designator, {@code MCH_EntityGLTD}: a tripod a player sits behind. Its + * sight looks up to 70 degrees either side of where it was set up, zooms and has night vision, + * and marking a spot calls in an A-10 for three gun runs over it. + */ +public class GltdEntity extends Entity { + /** How far the sight turns from the tripod's heading, either way, and up or down. */ + public static final float VIEW_LIMIT = 70.0F; + /** The CAS weapon the A-10s fire; the original hard coded it too. */ + private static final String WEAPON = "a10gau8"; + /** Added to the weapon's own {@code Delay} between two calls. */ + private static final int EXTRA_WAIT = 150; + private static final double SIGHT_RANGE = 300.0; + + private static final EntityDataAccessor DATA_WAIT = SynchedEntityData.defineId(GltdEntity.class, EntityDataSerializers.INT); + + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + private final CasStrike strike = new CasStrike(WEAPON); + private boolean nightVision; + + public GltdEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WAIT, 0); + } + + /** Ticks until the next A-10 can be called; the sight shows it. */ + public int waitTicks() { + return entityData.get(DATA_WAIT); + } + + /** The eye of the sight, a little in front of and above the tripod. */ + public Vec3 sightPosition(float partialTicks) { + float yaw = getYRot(partialTicks) * Mth.DEG_TO_RAD; + Vec3 pos = getPosition(partialTicks); + return new Vec3(pos.x - Mth.sin(yaw) * 0.6, pos.y + 0.7, pos.z + Mth.cos(yaw) * 0.6); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.04, 0.0); + if (onGround()) { + m = m.scale(0.5); + } + move(MoverType.SELF, m); + setDeltaMovement(m.x * 0.99, m.y * 0.95, m.z * 0.99); + + if (waitTicks() > 0) { + entityData.set(DATA_WAIT, waitTicks() - 1); + } + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (def != null && level() instanceof ServerLevel server) { + strike.tick(server, def); + } + Entity viewer = getFirstPassenger(); + if (viewer == null) { + nightVision = false; + } else if (nightVision) { + NightVision.topUp(viewer); + } + } + + /** + * {@code useCurrentWeapon}: marks the block {@code player} looks at through the sight and calls + * the A-10 on it. Server only. + * + * @return whether the strike was called; the sight may be waiting, or see nothing in range + */ + public boolean callStrike(ServerPlayer player) { + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (getFirstPassenger() != player || waitTicks() > 0 || def == null || !(level() instanceof ServerLevel server)) { + return false; + } + Vec3 from = sightPosition(1.0F); + Vec3 to = from.add(player.getLookAngle().scale(SIGHT_RANGE)); + BlockHitResult hit = server.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + strike.start(hit.getLocation(), player.getYRot(), this, player); + entityData.set(DATA_WAIT, def.delay + EXTRA_WAIT); + ModSounds.playAt(server, getX(), getY(), getZ(), "gltd", 0.5F, 1.0F); + if (def.sound != null) { + ModSounds.playTo(player, def.sound, 1.0F, 1.0F); + } + return true; + } + + public void setNightVision(Player player, boolean on) { + if (getFirstPassenger() != player) { + return; + } + nightVision = on; + if (!on) { + NightVision.end(player); + } + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + if (player.isSecondaryUseActive() || isVehicle()) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + player.setYRot(getYRot()); + player.setXRot(0.0F); + player.startRiding(this); + } + return InteractionResult.SUCCESS; + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (!level().isClientSide() && nightVision) { + NightVision.end(passenger); + nightVision = false; + } + } + + /** The viewer sits behind the tripod. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, -0.5).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + /** The original clamped the viewer's head the same way every tick. */ + @Override + public void onPassengerTurned(Entity passenger) { + float yaw = getYRot() + Mth.clamp(Mth.wrapDegrees(passenger.getYRot() - getYRot()), -VIEW_LIMIT, VIEW_LIMIT); + passenger.setYRot(yaw); + passenger.setYHeadRot(yaw); + passenger.setXRot(Mth.clamp(passenger.getXRot(), -VIEW_LIMIT, VIEW_LIMIT)); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved()) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + if (!creative) { + spawnAtLocation(level, ModItems.GLTD.get()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), "hit", 1.0F, 1.0F); + discard(); + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.GLTD.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java new file mode 100644 index 00000000..30290141 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java new file mode 100644 index 00000000..0f246c96 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -0,0 +1,552 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +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.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +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.
  • + *
  • Blades fold and unfold one after another at the same pace on both sides, from the + * synced fold state, instead of each client stepping its own copy.
  • + *
+ */ +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 static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + + private double currentSpeed = MIN_SPEED; + private float rotorAngle; + public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; + + 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; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, input.getBooleanOr("FoldBlade", false) ? FOLDED : UNFOLDED); + } + + /** 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() && canUseBlades()) { + setHovering(true); + } + } + + private boolean isLevelEnough() { + return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; + } + + @Override + public boolean canSwitchGunnerMode() { + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); + } + + @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; + } + // Standing on its skids or wheels a helicopter stays level; the mouse only tilts it in the air. + if (!isHovering() && !onGround()) { + 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 (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } + 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; + tickBlades(def); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 00000000..f52c4227 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 00000000..337f2816 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java @@ -0,0 +1,187 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A parachute with someone hanging under it, {@code MCH_EntityParachute}. It sinks at about two + * blocks a second, the rider steers it with the forward key, and it folds away a moment after it + * touches the ground or the water. + */ +public class ParachuteEntity extends Entity { + /** What the parachute looks like and how it opens, the original's model type. */ + public enum Kind { + /** From the parachute item: open from the start. */ + ITEM, + /** An ejection seat: the rider falls in the seat and the canopy opens once it drops. */ + EJECTION_SEAT, + /** Troops dropped from a rack or a cargo door: open from the start, no seat. */ + DROP + } + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(ParachuteEntity.class, EntityDataSerializers.INT); + private static final double MAX_SPEED = 0.35; + private static final double MIN_STEER = 0.07; + + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + private double steer = MIN_STEER; + private int onGroundCount; + /** Who boards on the next server tick; boarding in the same tick as the spawn can be lost. */ + private @Nullable Entity user; + + public ParachuteEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Opens a parachute above {@code user}, carrying over their speed, and puts them under it. + * Server only. + */ + public static ParachuteEntity open(Level level, Entity user, Kind kind) { + ParachuteEntity parachute = new ParachuteEntity(ModEntities.PARACHUTE.get(), level); + double lift = kind == Kind.ITEM ? 3.5 : 0.0; + parachute.setPos(user.getX(), user.getY() + lift, user.getZ()); + parachute.setYRot(user.getYRot()); + parachute.setDeltaMovement(user.getDeltaMovement()); + parachute.entityData.set(DATA_KIND, kind.ordinal()); + parachute.user = user; + user.resetFallDistance(); + level.addFreshEntity(parachute); + return parachute; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, Kind.ITEM.ordinal()); + } + + public Kind kind() { + Kind[] kinds = Kind.values(); + int i = entityData.get(DATA_KIND); + return i >= 0 && i < kinds.length ? kinds[i] : Kind.ITEM; + } + + /** An ejection seat only opens its canopy once it has started to fall. */ + public boolean isOpen() { + return kind() != Kind.EJECTION_SEAT || getDeltaMovement().y < -0.1; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + if (user != null) { + if (!user.isPassenger() && user.isAlive()) { + user.startRiding(this, true); + setYRot(user.getYRot()); + } + user = null; + } + Entity rider = getFirstPassenger(); + if (rider == null && tickCount > 20 || isInWater()) { + ejectPassengers(); + discard(); + return; + } + if (rider != null) { + rider.resetFallDistance(); + } + simulate(rider); + } + + /** {@code onUpdateServer}. */ + private void simulate(@Nullable Entity rider) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevSpeed = Math.sqrt(mx * mx + mz * mz); + boolean seatLaunch = kind() == Kind.EJECTION_SEAT && tickCount < 20; + my -= onGround() || seatLaunch ? 0.01 : 0.03; + + if (isOpen()) { + if (rider instanceof ServerPlayer player && player.getLastClientInput().forward()) { + double yaw = Math.toRadians(rider.getYRot()); + mx += -Math.sin(yaw) * steer * 0.05; + mz += Math.cos(yaw) * steer * 0.05; + } + double speed = Math.sqrt(mx * mx + mz * mz); + if (speed > MAX_SPEED) { + mx *= MAX_SPEED / speed; + mz *= MAX_SPEED / speed; + speed = MAX_SPEED; + } + // Holding course builds up speed; letting go settles back to a slow drift. + if (speed > prevSpeed) { + steer = Math.min(steer + (MAX_SPEED - steer) / 35.0, MAX_SPEED); + } else { + steer = Math.max(steer - (steer - MIN_STEER) / 35.0, MIN_STEER); + } + } + + if (onGround() && ++onGroundCount > 5) { + ejectPassengers(); + discard(); + return; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + my *= seatLaunch ? 0.95 : 0.9; + double drag = isOpen() ? 0.95 : 0.99; + setDeltaMovement(mx * drag, my, mz * drag); + if (rider != null) { + setYRot(rider.getYRot()); + } + } + + /** + * The server moves the parachute; clients ease towards each position it sends over three + * ticks, as for the vehicles. Without this a slow descent steps from one update to the next. + */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + entityData.set(DATA_KIND, input.getIntOr("Kind", Kind.ITEM.ordinal())); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putInt("Kind", kind().ordinal()); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/PartState.java new file mode 100644 index 00000000..54755d56 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java new file mode 100644 index 00000000..b80171b8 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 00000000..6fdd2ab1 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.entity; + +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java new file mode 100644 index 00000000..e729c7f5 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java new file mode 100644 index 00000000..92e8e59e --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 00000000..836e6d55 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,339 @@ +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.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.chat.Component; +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.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

The original kept the player at the station and had the drone read its pilot from there. + * Here the pilot takes the drone's reserved seat for as long as they fly it, hidden and unhurt, + * so the drone uses the same controls, cameras, HUD and weapons as every other vehicle. Leaving + * the drone, or losing it, puts the pilot back beside the station. + */ +public class UavStationEntity extends Entity { + public static final int STATION = 1; + public static final int PORTABLE = 2; + /** Further out than this, measured flat, the link breaks and the drone is lost, as in the original. */ + public static final double RANGE = 123.0; + /** The launch offset goes up to this far from the station along each axis. */ + public static final int MAX_OFFSET = 50; + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_OPEN = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_OFFSET = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BLOCK_POS); + private static final EntityDataAccessor DATA_LINKED = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + private final SimpleContainer slot = new SimpleContainer(1); + private @Nullable UUID lastUav; + /** The lid of the portable controller, 0 shut to 1 open. */ + private float cover; + private float previousCover; + + public UavStationEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, (byte) STATION); + builder.define(DATA_OPEN, true); + builder.define(DATA_OFFSET, new BlockPos(12, 2, 12)); + builder.define(DATA_LINKED, false); + } + + public int kind() { + return entityData.get(DATA_KIND); + } + + public void setKind(int kind) { + entityData.set(DATA_KIND, (byte) (kind == PORTABLE ? PORTABLE : STATION)); + } + + public boolean isOpen() { + return entityData.get(DATA_OPEN); + } + + /** Where a launched drone appears, relative to the station. */ + public BlockPos offset() { + return entityData.get(DATA_OFFSET); + } + + public void setOffset(BlockPos offset) { + entityData.set(DATA_OFFSET, new BlockPos(Mth.clamp(offset.getX(), -MAX_OFFSET, MAX_OFFSET), + Mth.clamp(offset.getY(), -MAX_OFFSET, MAX_OFFSET), Mth.clamp(offset.getZ(), -MAX_OFFSET, MAX_OFFSET))); + } + + /** Whether a drone is flown from here right now; the screen lights up. */ + public boolean isLinked() { + return entityData.get(DATA_LINKED); + } + + public void setLinked(boolean linked) { + entityData.set(DATA_LINKED, linked); + } + + public SimpleContainer slot() { + return slot; + } + + public float cover(float partialTicks) { + return Mth.lerp(partialTicks, previousCover, cover); + } + + /** + * {@code initUavPostion}: the first launch offset is twelve blocks out on the diagonal the + * station faces, two up. + */ + public void initOffset() { + int quarter = (int) (Mth.positiveModulo(getYRot() + 45.0F, 360.0F) / 90.0F) % 4; + int x = quarter == 0 || quarter == 3 ? 12 : -12; + int z = quarter == 0 || quarter == 1 ? 12 : -12; + setOffset(new BlockPos(x, 2, z)); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + previousCover = cover; + cover = Mth.clamp(cover + (isOpen() ? 0.1F : -0.1F), 0.0F, 1.0F); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.03, 0.0); + move(MoverType.SELF, new Vec3(0.0, m.y, 0.0)); + setDeltaMovement(0.0, onGround() ? 0.0 : m.y * 0.96, 0.0); + + Entity user = getFirstPassenger(); + if (user instanceof ServerPlayer player && !slot.getItem(0).isEmpty()) { + launch(player, slot.getItem(0)); + } + } + + /** What the slot holds, if it is a vehicle at all. */ + public static @Nullable AircraftDefinition definitionOf(ItemStack stack) { + String name = AircraftItem.vehicleOf(stack); + return name != null ? MCHeli.content().any(name) : null; + } + + /** Whether this station can put up the vehicle in {@code stack}. */ + public boolean accepts(@Nullable AircraftDefinition def) { + return def != null && def.uav && (kind() != PORTABLE || def.smallUav); + } + + /** {@code handleItem}: puts up the drone in the slot and hands it to {@code player}. */ + private void launch(ServerPlayer player, ItemStack stack) { + AircraftDefinition def = definitionOf(stack); + if (!accepts(def) || !(level() instanceof ServerLevel server)) { + return; + } + if (ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return; + } + AircraftEntity uav = ModEntities.typeFor(def).create(server, EntitySpawnReason.TRIGGERED); + if (uav == null) { + return; + } + BlockPos o = offset(); + double y = Math.max(getY() + o.getY(), server.getMinY() + 2.0); + uav.setType(def); + uav.snapTo(getX() + o.getX(), y, getZ() + o.getZ(), getYRot() - 180.0F, 0.0F); + if (!server.noCollision(uav, uav.getBoundingBox().deflate(0.1))) { + player.displayClientMessage(Component.translatable("message.mcheli.uav.blocked"), true); + slot.setItem(0, ItemStack.EMPTY); + Containers.dropItemStack(server, player.getX(), player.getY(), player.getZ(), stack); + return; + } + slot.setItem(0, ItemStack.EMPTY); + uav.setFuelLevel(def.targetDrone ? uav.maxFuel() : (int) (uav.maxFuel() * 0.05F)); + server.addFreshEntity(uav); + if (!def.targetDrone) { + player.closeContainer(); + control(player, uav); + } + } + + /** {@code controlLastAircraft}: takes the last drone flown from here again. */ + public boolean continueControl(ServerPlayer player) { + AircraftEntity uav = lastUav(); + if (uav == null || getFirstPassenger() != player || !uav.canTakeRemotePilot()) { + return false; + } + player.closeContainer(); + return control(player, uav); + } + + public @Nullable AircraftEntity lastUav() { + if (lastUav == null || !(level() instanceof ServerLevel server)) { + return null; + } + Entity e = server.getEntity(lastUav); + return e instanceof AircraftEntity uav && uav.isAlive() && !uav.isDestroyed() && inRange(uav) ? uav : null; + } + + public boolean inRange(Entity uav) { + double dx = uav.getX() - getX(); + double dz = uav.getZ() - getZ(); + return dx * dx + dz * dz <= RANGE * RANGE; + } + + private boolean control(ServerPlayer player, AircraftEntity uav) { + player.stopRiding(); + if (!uav.takeRemotePilot(player, this)) { + return false; + } + lastUav = uav.getUUID(); + setLinked(true); + player.setYRot(uav.getYRot()); + return true; + } + + /** Where a pilot comes back to when the drone is left or lost: the seat in front of the station. */ + public Vec3 returnPosition() { + float yaw = getYRot() * Mth.DEG_TO_RAD; + return new Vec3(getX() - Mth.sin(yaw) * 0.9, getY(), getZ() + Mth.cos(yaw) * 0.9); + } + + @Override + public InteractionResult interactAt(Player player, Vec3 location, InteractionHand hand) { + if (isVehicle()) { + return InteractionResult.PASS; + } + if (kind() == PORTABLE && player.isSecondaryUseActive()) { + if (!level().isClientSide()) { + entityData.set(DATA_OPEN, !isOpen()); + } + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive() || kind() == PORTABLE && !isOpen()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer server) { + player.setYRot(getYRot() - 180.0F); + if (player.startRiding(this)) { + server.openMenu(new SimpleMenuProvider((id, inventory, p) -> new UavStationMenu(id, inventory, this), + Component.translatable(kind() == PORTABLE ? "item.mcheli.uav_station2" : "item.mcheli.uav_station"))); + } + } + return InteractionResult.SUCCESS; + } + + /** The original sat the player down in front of the console, lower for the portable one. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + double y = kind() == PORTABLE ? -0.6 : 0.35; + return new Vec3(0.0, y, 0.9).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || damage <= 0.0F) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + AircraftEntity uav = lastUav(); + if (uav != null) { + uav.releaseRemotePilot(); + } + Containers.dropContents(level, blockPosition(), slot); + if (!creative) { + spawnAtLocation(level, item()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), source.getEntity() instanceof Player ? "hit" : "helidmg", 1.0F, 1.0F); + discard(); + return true; + } + + private Item item() { + return kind() == PORTABLE ? ModItems.UAV_CONTROLLER.get() : ModItems.UAV_STATION.get(); + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith() { + return true; + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(item()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + setKind(input.getIntOr("Kind", STATION)); + entityData.set(DATA_OPEN, input.getBooleanOr("Open", true)); + setOffset(new BlockPos(input.getIntOr("UavX", 12), input.getIntOr("UavY", 2), input.getIntOr("UavZ", 12))); + lastUav = input.read("LastUav", UUIDUtil.CODEC).orElse(null); + slot.setItem(0, input.read("Item", ItemStack.CODEC, registryAccess().createSerializationContext(NbtOps.INSTANCE)).orElse(ItemStack.EMPTY)); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putInt("Kind", kind()); + output.putBoolean("Open", isOpen()); + BlockPos o = offset(); + output.putInt("UavX", o.getX()); + output.putInt("UavY", o.getY()); + output.putInt("UavZ", o.getZ()); + if (lastUav != null) { + output.store("LastUav", UUIDUtil.CODEC, lastUav); + } + if (!slot.getItem(0).isEmpty()) { + output.store("Item", ItemStack.CODEC, registryAccess().createSerializationContext(NbtOps.INSTANCE), slot.getItem(0)); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleInterpolation.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleInterpolation.java new file mode 100644 index 00000000..5ba61eb5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleInterpolation.java @@ -0,0 +1,25 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.phys.Vec3; + +/** + * Eases server position updates in, and keeps the target when one carries only a rotation. On this + * version the client answers those by interpolating to where the entity is right now, which would + * leave a turning vehicle short of its real position until the next full sync. + */ +public final class VehicleInterpolation extends InterpolationHandler { + private final Entity entity; + + public VehicleInterpolation(Entity entity) { + super(entity, 3); + this.entity = entity; + } + + @Override + public void interpolateTo(Vec3 position, float yRot, float xRot) { + boolean rotationOnly = hasActiveInterpolation() && position.equals(entity.position()); + super.interpolateTo(rotationOnly ? position() : position, yRot, xRot); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java new file mode 100644 index 00000000..7dd7045a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.supply.SupplyRules; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +/** + * What a vehicle carries besides its crew, {@code MCH_AircraftInventory} and the container half of + * {@code W_EntityContainer}: the equipment slots of the vehicle screen (three fuel cans and a + * parachute per ejection seat) and the cargo chest of vehicles with an {@code InventorySize}. + * + *

The cargo chest always has room for the largest size, 54 slots, and the screen shows as many + * rows as the definition asks for. Items in rows a content reload took away are kept rather than + * lost, and still drop when the vehicle is destroyed. + */ +public final class VehicleInventory { + public static final int FUEL_SLOTS = 3; + public static final int PARACHUTE_SLOTS = 2; + public static final int EQUIPMENT_SLOTS = FUEL_SLOTS + PARACHUTE_SLOTS; + public static final int CARGO_SLOTS = 54; + /** How far from the vehicle's centre a player outside may keep a screen open, past its width. */ + private static final double REACH = 12.0; + + private final AircraftEntity vehicle; + private final SimpleContainer equipment = new GuardedContainer(EQUIPMENT_SLOTS); + private final SimpleContainer cargo = new GuardedContainer(CARGO_SLOTS); + + VehicleInventory(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + public SimpleContainer equipment() { + return equipment; + } + + public SimpleContainer cargo() { + return cargo; + } + + /** {@code isUseableByPlayer}: the crew, or someone standing next to the vehicle. */ + public boolean stillValid(Player player) { + if (vehicle.isRemoved() || vehicle.definition() == null) { + return false; + } + if (player.getVehicle() == vehicle) { + return true; + } + double reach = REACH + vehicle.definition().bodyWidth / 2.0; + return player.distanceToSqr(vehicle) <= reach * reach; + } + + // Fuel + + /** + * {@code updateFuel}: every half second, while the vehicle stands on something, each fuel can + * in a fuel slot pours up to 100 units into the tank. Server only. + * + * @return whether any fuel went in + */ + boolean pourFuelCans(int ticks) { + if (ticks % SupplyRules.FUEL_SLOT_INTERVAL != 0 || !vehicle.canSupply()) { + return false; + } + boolean poured = false; + for (int slot = 0; slot < FUEL_SLOTS; slot++) { + ItemStack can = equipment.getItem(slot); + if (!(can.getItem() instanceof FuelItem)) { + continue; + } + int amount = SupplyRules.fuelFromCan(vehicle.maxFuel() - vehicle.fuel(), FuelItem.contents(can)); + if (amount > 0) { + vehicle.refuel(amount); + can.setDamageValue(can.getDamageValue() + amount); + poured = true; + } + } + if (poured) { + equipment.setChanged(); + } + return poured; + } + + // Parachutes + + /** Parachute slots the vehicle screen offers: one per ejection seat. */ + public static int parachuteSlots(AircraftDefinition def) { + return Math.min(def.ejectionSeats(), PARACHUTE_SLOTS); + } + + /** + * {@code consumeParachute}: takes one parachute out of the parachute slots for a crew member + * who bails out. + * + * @return false when there is none left + */ + boolean takeParachute() { + for (int slot = FUEL_SLOTS; slot < EQUIPMENT_SLOTS; slot++) { + if (equipment.getItem(slot).getItem() instanceof ParachuteItem) { + equipment.removeItem(slot, 1); + return true; + } + } + return false; + } + + // Saving and dropping + + void save(CompoundTag output, HolderLookup.Provider registries) { + output.put("Equipment", ContainerHelper.saveAllItems(new CompoundTag(), equipment.getItems(), false, registries)); + output.put("Cargo", ContainerHelper.saveAllItems(new CompoundTag(), cargo.getItems(), false, registries)); + } + + void load(CompoundTag input, HolderLookup.Provider registries) { + equipment.clearContent(); + cargo.clearContent(); + input.getCompound("Equipment").ifPresent(in -> ContainerHelper.loadAllItems(in, equipment.getItems(), registries)); + input.getCompound("Cargo").ifPresent(in -> ContainerHelper.loadAllItems(in, cargo.getItems(), registries)); + } + + /** {@code setDead}: everything aboard falls out where the vehicle was. Server only. */ + void dropAll() { + Containers.dropContents(vehicle.level(), vehicle, equipment); + Containers.dropContents(vehicle.level(), vehicle, cargo); + } + + /** A container that only stays open for players {@link #stillValid(Player)} allows. */ + private final class GuardedContainer extends SimpleContainer { + GuardedContainer(int size) { + super(size); + } + + @Override + public boolean stillValid(Player player) { + return VehicleInventory.this.stillValid(player); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java new file mode 100644 index 00000000..46ed88a8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.ParticleSplash; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * The spray, dust and exhaust a vehicle throws up, all client side: + *

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java new file mode 100644 index 00000000..8a695ef4 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java @@ -0,0 +1,159 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +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(), EntitySpawnReason.TRIGGERED); + 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 hurtServer(ServerLevel level, 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 input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java new file mode 100644 index 00000000..a9d41520 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java new file mode 100644 index 00000000..e5f46c96 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java new file mode 100644 index 00000000..d7031ecf --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java new file mode 100644 index 00000000..c758d75b --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java new file mode 100644 index 00000000..b2b418ac --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java new file mode 100644 index 00000000..69a2f674 --- /dev/null +++ b/minecraft-1.21.5/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.getMinY() || pos.getY() >= server.getMaxY()) { + 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.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java new file mode 100644 index 00000000..cfd73b74 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java @@ -0,0 +1,160 @@ +package com.jnhprojects.mcheli.entity.projectile; + +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.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.nbt.CompoundTag; +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) { + snapTo(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 hurtServer(ServerLevel level, 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 input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java new file mode 100644 index 00000000..eb723310 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java @@ -0,0 +1,139 @@ +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.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; + +/** + * 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(rgb, 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(), EntitySpawnReason.TRIGGERED); + 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().getMaxY(); 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public String defaultModel() { + return "rocket"; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java new file mode 100644 index 00000000..6ca981af --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -0,0 +1,789 @@ +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.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.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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.nbt.CompoundTag; +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); + /** Who fired, for clients: their rounds must not stop on their own vehicle. */ + private static final EntityDataAccessor DATA_SHOOTER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_USER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + + /** 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; + /** Client only: the round hit something here and waits, hidden, for the server to remove it. */ + private boolean spent; + + 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); + builder.define(DATA_SHOOTER, 0); + builder.define(DATA_USER, 0); + } + + /** + * 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) { + snapTo(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; + setOwners(shooter, 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; + setOwners(parent.shooter, parent.user); + } + + public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + entityData.set(DATA_SHOOTER, shooter != null ? shooter.getId() : 0); + entityData.set(DATA_USER, user != null ? user.getId() : 0); + } + + 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 (spent) { + return; + } + 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().getMaxY() && !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(), EntitySpawnReason.TRIGGERED); + 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; + } + + /** Clients only learn who fired through synched data; look them up once it has arrived. */ + private void resolveOwners() { + if (shooter == null && entityData.get(DATA_SHOOTER) > 0) { + shooter = level().getEntity(entityData.get(DATA_SHOOTER)); + } + if (user == null && entityData.get(DATA_USER) > 0) { + user = level().getEntity(entityData.get(DATA_USER)); + } + } + + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ + public boolean canHit(Entity entity) { + if (level().isClientSide()) { + resolveOwners(); + } + 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); + } + if (piercing <= 0) { + // The server decides what the hit does; until its removal arrives the round stays + // where it struck instead of flying on through the hull. + spent = true; + setPos(hit.location()); + setDeltaMovement(Vec3.ZERO); + } + 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 hurtServer(ServerLevel level, 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; + } + + /** Client only: whether the round already hit something and should no longer be drawn. */ + public boolean isSpent() { + return spent; + } + + @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 input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + output.putString("WeaponName", weaponName()); + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java new file mode 100644 index 00000000..c39fa6fe --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 00000000..8466f9c6 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,200 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.VehicleInterpolation; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +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.util.ARGB; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private final InterpolationHandler interpolation = new VehicleInterpolation(this); + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = ARGB.redFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float g = ARGB.greenFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float b = ARGB.blueFloat(color) * 0.9F + random.nextFloat() * 0.1F; + if (ARGB.red(color) == ARGB.green(color)) { + g = r; + } + if (ARGB.red(color) == ARGB.blue(color)) { + b = r; + } + if (ARGB.green(color) == ARGB.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(ARGB.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(CompoundTag input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(CompoundTag output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java new file mode 100644 index 00000000..a89f7c01 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java new file mode 100644 index 00000000..a06fef1a --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 00000000..c296bffa --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,146 @@ +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.content.pack.PackManifest; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; +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.function.Consumer; + +/** + * 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; 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)) { + stack.set(DataComponents.ITEM_MODEL, ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + 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); + } + + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, Consumer out, TooltipFlag flag) { + super.appendHoverText(stack, context, display, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.accept((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { + return InteractionResult.PASS; + } + + 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 InteractionResult.FAIL; + } + if (!level.isClientSide() && ServerConfig.isDisabled(def.name())) { + player.displayClientMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName()), true); + return InteractionResult.FAIL; + } + + AircraftEntity vehicle = ModEntities.typeFor(def).create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (vehicle == null) { + return InteractionResult.FAIL; + } + Vec3 at = blockHit.getLocation(); + vehicle.setType(def); + vehicle.snapTo(at.x, at.y, at.z, player.getYRot(), 0.0F); + if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { + return InteractionResult.FAIL; + } + + 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 InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 00000000..0f89341b --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.removed"), true); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.displayClientMessage(Component.translatable("message.mcheli.chain.released"), true); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance)), true); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.mcheli.chain.no_vehicle"), true); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.displayClientMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName()), true); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 00000000..efa8f295 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * The fuel can, {@code MCH_ItemFuel}. It holds 600 units; the durability bar shows how much is + * left. Using it refills it from the coal and charcoal in the inventory, as in the original. + * + *

A can in a fuel slot of the vehicle screen empties into the tank while the vehicle stands on + * something, as in the original. Not in the original: right-clicking a vehicle with the can pours + * up to 100 units at once, which saves opening the screen. + */ +public class FuelItem extends Item { + /** Units a full can holds, the original's maximum damage. */ + public static final int CAPACITY = 600; + /** Units poured into a vehicle per click: what the original moved per fuel slot per update. */ + private static final int POUR = 100; + private static final int PER_COAL = 100; + private static final int PER_CHARCOAL = 75; + + public FuelItem(Properties properties) { + super(properties.durability(CAPACITY)); + } + + public static int contents(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + /** Fills a vehicle's tank from the can. Server only. */ + public static InteractionResult pour(ItemStack stack, AircraftEntity vehicle, Player player) { + int added = vehicle.refuel(player.getAbilities().instabuild ? POUR : Math.min(contents(stack), POUR)); + if (added <= 0) { + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) { + stack.setDamageValue(stack.getDamageValue() + added); + } + vehicle.level().playSound(null, vehicle.getX(), vehicle.getY(), vehicle.getZ(), SoundEvents.BUCKET_EMPTY, SoundSource.NEUTRAL, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } + + /** Charcoal first, then coal, each one worth part of the can, until it is full. */ + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + if (!stack.isDamaged() || player.getAbilities().instabuild) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + refill(stack, player.getInventory(), Items.CHARCOAL, PER_CHARCOAL); + refill(stack, player.getInventory(), Items.COAL, PER_COAL); + } + return InteractionResult.SUCCESS; + } + + private static void refill(ItemStack can, Inventory inventory, Item coal, int worth) { + for (int slot = 0; slot < inventory.getContainerSize() && can.isDamaged(); slot++) { + ItemStack item = inventory.getItem(slot); + while (item.is(coal) && !item.isEmpty() && can.isDamaged()) { + can.setDamageValue(Math.max(can.getDamageValue() - worth, 0)); + item.shrink(1); + } + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 00000000..a83ff5bd --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 00000000..0a286838 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java @@ -0,0 +1,61 @@ +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.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +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, ServerLevel level, Entity owner, @Nullable EquipmentSlot slot) { + if (!(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.5/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 00000000..2af7b79b --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.Level; + +/** The parachute, {@code MCH_ItemParachute}: used in mid-air, it opens above the player. */ +public class ParachuteItem extends Item { + public ParachuteItem(Properties properties) { + super(properties.stacksTo(1)); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (player.isPassenger() || player.onGround()) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + ParachuteEntity.open(level, player, ParachuteEntity.Kind.ITEM); + player.getItemInHand(hand).consume(1, player); + } + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 00000000..34c524d5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.snapTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 00000000..6a4fb996 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.SPYGLASS; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 00000000..8976ab52 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,123 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + stack.set(DataComponents.ITEM_MODEL, ResourceLocation.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResult.FAIL; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } + + @Override + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return false; + } + int drawn = Math.max(getUseDuration(stack, entity) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + return true; + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 00000000..7e3bc1c8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 00000000..05c610e1 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java @@ -0,0 +1,67 @@ +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 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 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 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(AircraftItem.stackOf(def)) + : SlotDisplay.Empty.INSTANCE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 00000000..627b94c0 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java @@ -0,0 +1,43 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * The wrench, {@code MCH_ItemWrench}. Right-clicking a damaged vehicle repairs 10 hit points, once + * a second, at one point of the wrench's durability. Sneaking switches the vehicle to its next + * paint scheme. + * + *

The original repaired while the use key was held; a click per repair works the same with + * today's item use, which stops as soon as the cursor leaves the hull. + */ +public class WrenchItem extends Item { + /** An iron tool's durability, the original's tool material. */ + private static final int DURABILITY = 250; + private static final int REPAIR = 10; + private static final int COOLDOWN = 20; + + public WrenchItem(Properties properties) { + super(properties.durability(DURABILITY)); + } + + /** Server only. */ + public static InteractionResult useOn(ItemStack stack, AircraftEntity vehicle, Player player, InteractionHand hand) { + if (player.isSecondaryUseActive()) { + return vehicle.nextTexture() ? InteractionResult.SUCCESS : InteractionResult.FAIL; + } + if (player.getCooldowns().isOnCooldown(stack) || !vehicle.repair(REPAIR)) { + return InteractionResult.FAIL; + } + player.getCooldowns().addCooldown(stack, COOLDOWN); + stack.hurtAndBreak(1, player, LivingEntity.getSlotForHand(hand)); + ModSounds.playAt(vehicle, "wrench", 1.0F, 0.9F + vehicle.getRandom().nextFloat() * 0.2F); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 00000000..c38c9530 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,148 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes in the player's recipe book and asks for one by its + * display id; the server finds the recipe, takes the ingredients from the inventory and puts the + * result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addStandardInventorySlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, RecipeDisplayId id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeManager.ServerDisplayInfo info = player.level().getServer().getRecipeManager().getRecipeFromDisplay(id); + if (info == null) { + return; + } + RecipeHolder holder = info.parent(); + if (!holder.id().location().getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + List ingredients = recipe.placementInfo().ingredients(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.assemble(CraftingInput.EMPTY, player.registryAccess())); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 00000000..4c5f7bd8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addStandardInventorySlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java new file mode 100644 index 00000000..b2dba1e4 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java @@ -0,0 +1,217 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGuiContainer}: fuel slots, a parachute slot per ejection + * seat, reloading the weapons from the crew member's own items and the way into the cargo chest. + * + *

Only crew open it, so the client finds the vehicle as the one its player rides. The weapon + * rounds and which weapons can be reloaded right now come from the server through data slots, + * since a client only hears about the ammunition of the seat it sits in. + */ +public class VehicleMenu extends AbstractContainerMenu { + /** Weapons the screen can show; more than any vehicle in the original has. */ + public static final int MAX_WEAPONS = 32; + /** Button ids from zero up reload that weapon; this one opens the cargo chest. */ + public static final int BUTTON_CARGO = 1000; + + public static final int FUEL_X = 8; + public static final int FUEL_Y = 30; + public static final int PARACHUTE_X = 8; + public static final int PARACHUTE_Y = 66; + public static final int INVENTORY_Y = 102; + + private static final int DATA_RELOADABLE = MAX_WEAPONS; + private static final int DATA_SIZE = MAX_WEAPONS + 2; + + private final @Nullable AircraftEntity vehicle; + private final Container equipment; + private final ContainerData data; + private final int parachuteSlots; + private boolean supplied; + + /** The client side, from the open screen packet. */ + public VehicleMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof AircraftEntity v ? v : null, + new SimpleContainer(VehicleInventory.EQUIPMENT_SLOTS), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public VehicleMenu(int containerId, Inventory inventory, AircraftEntity vehicle) { + this(containerId, inventory, vehicle, vehicle.inventory().equipment(), new ServerData(vehicle, inventory.player)); + } + + private VehicleMenu(int containerId, Inventory inventory, @Nullable AircraftEntity vehicle, Container equipment, ContainerData data) { + super(ModMenus.VEHICLE.get(), containerId); + this.vehicle = vehicle; + this.equipment = equipment; + this.data = data; + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + parachuteSlots = def != null ? VehicleInventory.parachuteSlots(def) : 0; + checkContainerSize(equipment, VehicleInventory.EQUIPMENT_SLOTS); + + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + addSlot(new Slot(equipment, i, FUEL_X + i * 18, FUEL_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof FuelItem; + } + }); + } + for (int i = 0; i < VehicleInventory.PARACHUTE_SLOTS; i++) { + boolean active = i < parachuteSlots; + addSlot(new Slot(equipment, VehicleInventory.FUEL_SLOTS + i, PARACHUTE_X + i * 18, PARACHUTE_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return active && stack.getItem() instanceof ParachuteItem; + } + + @Override + public boolean isActive() { + return active; + } + }); + } + addStandardInventorySlots(inventory, 8, INVENTORY_Y); + addDataSlots(data); + } + + public @Nullable AircraftEntity vehicle() { + return vehicle; + } + + /** Parachute slots in use: one per ejection seat. */ + public int parachuteSlots() { + return parachuteSlots; + } + + /** Magazine and reserve of a weapon together, as the server last reported them. */ + public int rounds(int weapon) { + return weapon >= 0 && weapon < MAX_WEAPONS ? data.get(weapon) : 0; + } + + /** Whether the server would reload this weapon now: the vehicle is down and the items are there. */ + public boolean canReload(int weapon) { + if (weapon < 0 || weapon >= MAX_WEAPONS) { + return false; + } + int bits = (data.get(DATA_RELOADABLE) & 0xFFFF) | (data.get(DATA_RELOADABLE + 1) & 0xFFFF) << 16; + return (bits & 1 << weapon) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (vehicle == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CARGO) { + return vehicle.openCargo(server); + } + if (id >= 0 && id < MAX_WEAPONS && Resupply.supply(server, vehicle, id)) { + supplied = true; + return true; + } + return false; + } + + @Override + public boolean stillValid(Player player) { + return vehicle != null && equipment.stillValid(player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + int equipmentEnd = VehicleInventory.EQUIPMENT_SLOTS; + if (index < equipmentEnd) { + if (!moveItemStackTo(stack, equipmentEnd, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof FuelItem) { + if (!moveItemStackTo(stack, 0, VehicleInventory.FUEL_SLOTS, false)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof ParachuteItem && parachuteSlots > 0) { + int first = VehicleInventory.FUEL_SLOTS; + if (!moveItemStackTo(stack, first, first + parachuteSlots, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** As in the original, closing the screen after a resupply loads every magazine from its reserve. */ + @Override + public void removed(Player player) { + super.removed(player); + if (supplied && vehicle != null && vehicle.weapons() != null && !player.level().isClientSide()) { + vehicle.weapons().reloadAll(); + } + } + + /** Rounds per weapon and the reload bits, worked out on the server whenever the menu syncs. */ + private record ServerData(AircraftEntity vehicle, Player player) implements ContainerData { + @Override + public int get(int index) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons == null) { + return 0; + } + if (index < MAX_WEAPONS) { + WeaponSet set = weapons.set(index); + // Data slots travel as shorts. + return set != null ? Math.min(set.ammo() + set.restAmmo(), Short.MAX_VALUE) : 0; + } + int bits = 0; + if (player instanceof ServerPlayer server) { + for (int i = 0; i < Math.min(weapons.sets().size(), MAX_WEAPONS); i++) { + if (Resupply.canSupply(server, vehicle, i)) { + bits |= 1 << i; + } + } + } + return index == DATA_RELOADABLE ? bits & 0xFFFF : bits >>> 16; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java new file mode 100644 index 00000000..0f7ccf72 --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java new file mode 100644 index 00000000..1b565e21 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -0,0 +1,164 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +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, + /** Open the vehicle screen: fuel and parachute slots, reloading, the way to the cargo chest. */ + VEHICLE_SCREEN, + /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ + GUNNER_MODE, + /** Turn night vision on for the sender, in vehicles with {@code EnableNightVision}. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT + } + + 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 VEHICLE_SCREEN -> vehicle.openScreen(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 00000000..46294aa3 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +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.item.crafting.display.RecipeDisplayId; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe the player's recipe book knows by this display id. + */ +public record DraftingTablePacket(RecipeDisplayId recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + RecipeDisplayId.STREAM_CODEC.encode(buf, msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(RecipeDisplayId.STREAM_CODEC.decode(buf)); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 00000000..76090f2e --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +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 request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket 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 equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java new file mode 100644 index 00000000..279b1511 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java new file mode 100644 index 00000000..0056dcc0 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 00000000..0f93dc7a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,119 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.serverLevel(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED) + : ModEntities.AA_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java new file mode 100644 index 00000000..97d2cbf4 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 00000000..1de0bfc3 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +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 client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java new file mode 100644 index 00000000..74ee6247 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,90 @@ +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 = 11; + + /** 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<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), + new Serverbound<>(TestFlightPacket.TYPE, TestFlightPacket.STREAM_CODEC, TestFlightPacket::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), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + 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), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 00000000..89c1644d --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 00000000..ad7f0c85 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 00000000..50f10f74 --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..1bc4d037 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

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

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.nonCriticalIoPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 00000000..e522f525 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,158 @@ +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); + } + PackSubmissions.onLogin(player); + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); + } + + /** The packs the server uses now, as the client should load them. */ + 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java new file mode 100644 index 00000000..6426f060 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 00000000..1ee72286 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +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.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 00000000..14b2db42 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +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 button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java new file mode 100644 index 00000000..db12a390 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java @@ -0,0 +1,34 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.workshop.TestFlight; +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; + +/** The workshop's Test flight button: fly the vehicle just saved. The server checks who is asking. */ +public record TestFlightPacket(String vehicle) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("test_flight"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TestFlightPacket::encode, TestFlightPacket::decode); + private static final int MAX_NAME = 64; + + public static void encode(TestFlightPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.vehicle, MAX_NAME); + } + + public static TestFlightPacket decode(FriendlyByteBuf buf) { + return new TestFlightPacket(buf.readUtf(MAX_NAME)); + } + + public static void handle(TestFlightPacket msg, ServerPlayer sender) { + if (sender != null) { + TestFlight.start(sender, msg.vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java new file mode 100644 index 00000000..292f643e --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 00000000..63a3577f --- /dev/null +++ b/minecraft-1.21.5/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( + ExtraCodecs.RGB_COLOR_CODEC.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.5/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 00000000..29a0b0ef --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +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 puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + ExtraCodecs.RGB_COLOR_CODEC.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 00000000..22892ad7 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/platform/Platform.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * 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); + + /** A block entity type; vanilla's constructor is private on 1.21.5. */ + BlockEntityType blockEntityType(BiFunction factory, Block block); + + final class Holder { + private static volatile Platform instance; + + private Holder() { + } + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 00000000..e90f274f --- /dev/null +++ b/minecraft-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 00000000..67d899aa --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 00000000..00d40aed --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,21 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +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.world.level.block.entity.BlockEntityType; + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> Platform.get().blockEntityType(DraftingTableBlockEntity::new, ModBlocks.DRAFTING_TABLE.get())); + + private ModBlockEntities() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 00000000..19995a92 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of().setId(key) + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java new file mode 100644 index 00000000..870aa8b8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -0,0 +1,72 @@ +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.item.ThrowableItem; +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, and one for + * the equipment and throwables. Those lists are 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)); + public static final Registered EQUIPMENT = Registration.register(Registries.CREATIVE_MODE_TAB, "equipment", + key -> Platform.get().creativeTab(key(VEHICLES)) + .title(Component.translatable("itemGroup.mcheli.equipment")) + .icon(() -> new ItemStack(ModItems.WRENCH.get())) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) + .build()); + + 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(), EQUIPMENT.get()); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 00000000..377a5783 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,34 @@ +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()); + + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + 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-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 00000000..b6686ccd --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,170 @@ +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.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.ThrowableEntity; +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)); + + public static final Registered> PARACHUTE = Registration.register(Registries.ENTITY_TYPE, "parachute", key -> EntityType.Builder + .of(ParachuteEntity::new, MobCategory.MISC) + .sized(1.5F, 0.6F) + .clientTrackingRange(10) + .updateInterval(1) + .build(key)); + + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key)); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key)); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key)); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key)); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .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-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 00000000..e7415dfd --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,102 @@ +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.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; +import com.jnhprojects.mcheli.item.WrenchItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; + +import java.util.List; +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. The + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. + */ +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"); + + public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties().setId(key))); + public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties().setId(key))); + public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties().setId(key))); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties().setId(key))); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties().setId(key))); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties().setId(key))); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties().setId(key).useBlockDescriptionPrefix())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties().setId(key))); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); + + 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) || EQUIPMENT.stream().anyMatch(e -> e.id().getPath().equals(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-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java new file mode 100644 index 00000000..0dac0052 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.MenuType; + +/** Our screens with slots. The cargo chest uses vanilla's chest menus and needs no entry here. */ +public final class ModMenus { + public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", + key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); + + private ModMenus() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 00000000..4ed53acd --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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; + } + }); + + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 00000000..4d869373 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 00000000..4e6d4ae5 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,112 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + /** The colours a team can have. */ + public static final List COLORS = Arrays.stream(ChatFormatting.values()).filter(ChatFormatting::isColor).toList(); + + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.hasPermissions(2); + } + + /** Switches the server's PVP setting until the next restart and says which way it went. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !server.isPvpAllowed(); + server.setPvpAllowed(pvp); + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.displayClientMessage(Component.translatable("message.mcheli.team.no_teams"), true); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.displayClientMessage(Component.translatable("message.mcheli.team.bad_name", trimmed), true); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(COLORS.get(Math.floorMod(color, COLORS.size()))); + team.setAllowFriendlyFire(friendlyFire); + player.displayClientMessage(Component.translatable("message.mcheli.team.created", trimmed), true); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + target.teleport(target.findRespawnPositionAndUseSpawnBlock(false, TeleportTransition.DO_NOTHING)); + } + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.displayClientMessage(Component.translatable("message.mcheli.team.destroyed", count), true); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 00000000..e78278a8 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,125 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + 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, EntitySpawnReason.TRIGGERED); + if (a10 == null) { + return; + } + a10.setPos(x, y, z); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java new file mode 100644 index 00000000..d94d3778 --- /dev/null +++ b/minecraft-1.21.5/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 level)) { + return false; + } + if (target instanceof LivingEntity living) { + living.invulnerableTime = 0; + } + 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, 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java new file mode 100644 index 00000000..4f4b0dc8 --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java new file mode 100644 index 00000000..f7fe3e89 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -0,0 +1,217 @@ +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.EntitySpawnReason; +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.ServerExplosion; +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, EntitySpawnReason.TRIGGERED); + 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 = ServerExplosion.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.5/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java new file mode 100644 index 00000000..12ddc658 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java @@ -0,0 +1,195 @@ +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.entity.EntitySpawnReason; +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(), EntitySpawnReason.TRIGGERED); + 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java new file mode 100644 index 00000000..b8142e92 --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java new file mode 100644 index 00000000..100e5ce1 --- /dev/null +++ b/minecraft-1.21.5/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.getValue(key); + return item == Items.AIR ? null : item; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java new file mode 100644 index 00000000..73d53c7b --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java new file mode 100644 index 00000000..533f5fba --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -0,0 +1,82 @@ +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 a crew member's own items, the {@code supplyAmmo} path of the + * original's vehicle screen. One supply costs the weapon's {@code Item} lines and adds + * {@code SuppliedNum} rounds; it only works while the vehicle stands on something. + */ +public final class Resupply { + private Resupply() { + } + + /** {@code canPlayerSupplyAmmo}: whether {@link #supply} would do anything right now. */ + public static boolean canSupply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.set(weapon) : null; + if (set == null || vehicle.isDestroyed() || player.getVehicle() != vehicle || !vehicle.canSupply()) { + return false; + } + if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { + return false; + } + return player.getAbilities().instabuild || canPay(player.getInventory(), set.definition()); + } + + /** Pays for and adds one supply of {@code weapon}. Server only. */ + public static boolean supply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + if (!canSupply(player, vehicle, weapon)) { + return false; + } + WeaponSet set = vehicle.weapons().set(weapon); + if (!player.getAbilities().instabuild) { + pay(player.getInventory(), set.definition()); + } + 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java new file mode 100644 index 00000000..3f867744 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -0,0 +1,418 @@ +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.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.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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, EntitySpawnReason.TRIGGERED); + 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, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; + } + + private static void sound(WeaponSet set, Entity at, String name) { + if (set.soundAllowed) { + WeaponDefinition w = set.definition(); + float rnd = w.soundPitchRandom; + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + } + } + + // 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) { + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? soundName(set) : "ng"); + return true; + } + List spotted = new ArrayList<>(); + 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; + } + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); + } + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "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; + set.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); + set.casUser = shot.user; + set.casShooter = shot.shooter; + set.casStartTick = CasStrike.FIRST_PASS; + 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; + } + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); + set.casStartTick = set.ticksSinceShot(0) + 45; + set.casCount++; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java new file mode 100644 index 00000000..6a9f53cf --- /dev/null +++ b/minecraft-1.21.5/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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 00000000..10167f4a --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +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 java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.serverLevel(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.serverLevel(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} diff --git a/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java new file mode 100644 index 00000000..af94a9dc --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -0,0 +1,598 @@ +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()) { + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); + } + } + 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(rgb, 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(rgb, 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java new file mode 100644 index 00000000..3f539030 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -0,0 +1,505 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +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.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; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; + + /** Aim relative to the hull, following the crew member who operates the weapon. */ + public float rotationYaw; + 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); + } + } + + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + + // 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/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java new file mode 100644 index 00000000..8705ed01 --- /dev/null +++ b/minecraft-1.21.5/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; + +/** + * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server + * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. + */ +public final class TestFlight { + /** How far up to look for room when the vehicle does not fit where the player stands. */ + private static final int MAX_RISE = 48; + + private TestFlight() { + } + + public static void start(ServerPlayer player, String vehicle) { + MinecraftServer server = player.level().getServer(); + if (!server.isSingleplayerOwner(player.getGameProfile())) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_owner_only"), true); + return; + } + if (player.isPassenger()) { + player.displayClientMessage(Component.translatable("message.mcheli.workshop.test_flight_riding"), true); + return; + } + ContentReload.reload(server); + AircraftDefinition def = MCHeli.content().any(vehicle); + if (def == null) { + player.sendSystemMessage(Component.translatable("message.mcheli.workshop.test_flight_no_vehicle", vehicle)); + MCHeli.packs().reportProblems(player::sendSystemMessage); + return; + } + ServerLevel level = player.serverLevel(); + AircraftEntity aircraft = ModEntities.typeFor(def).create(level, EntitySpawnReason.COMMAND); + if (aircraft == null) { + return; + } + aircraft.setType(def); + Vec3 start = player.position(); + for (int rise = 0; rise <= MAX_RISE; rise++) { + aircraft.snapTo(start.x, start.y + rise, start.z, player.getYRot(), 0.0F); + if (level.noCollision(aircraft, aircraft.getBoundingBox())) { + break; + } + } + aircraft.startTestFlight(start); + level.addFreshEntity(aircraft); + player.startRiding(aircraft, true); + } +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 00000000..82992af9 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/items/drafting_table.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/drafting_table.json new file mode 100644 index 00000000..a3afc683 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/drafting_table.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "mcheli:block/drafting_table" + } +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/items/helicopter.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/helicopter.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/helicopter.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/items/plane.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/plane.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/plane.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/items/tank.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/tank.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/items/vehicle.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/vehicle.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/items/vehicle.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 00000000..8dfa5bcc --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 00000000..296e5397 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 00000000..977381be --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/post_effect/thermal.json b/minecraft-1.21.5/src/main/resources/assets/mcheli/post_effect/thermal.json new file mode 100644 index 00000000..e9341395 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/post_effect/thermal.json @@ -0,0 +1,36 @@ +{ + "targets": { + "swap": {} + }, + "passes": [ + { + "vertex_shader": "minecraft:post/blit", + "fragment_shader": "mcheli:post/thermal", + "inputs": [ + { + "sampler_name": "In", + "target": "minecraft:main" + } + ], + "output": "swap" + }, + { + "vertex_shader": "minecraft:post/blit", + "fragment_shader": "minecraft:post/blit", + "inputs": [ + { + "sampler_name": "In", + "target": "swap" + } + ], + "uniforms": [ + { + "name": "ColorModulate", + "type": "vec4", + "values": [1.0, 1.0, 1.0, 1.0] + } + ], + "output": "minecraft:main" + } + ] +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/shaders/post/thermal.fsh b/minecraft-1.21.5/src/main/resources/assets/mcheli/shaders/post/thermal.fsh new file mode 100644 index 00000000..91ec30c8 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/assets/mcheli/shaders/post/thermal.fsh @@ -0,0 +1,34 @@ +#version 150 + +// Thermal view, white hot. A post shader only sees colours, so heat is faked: bright and warm +// colours (fire, lava, skin, lamps) read hot, the sky and water cold, and outlines are drawn +// where the brightness changes sharply, like the original's "pencil" shader. + +uniform sampler2D InSampler; + +in vec2 texCoord; + +uniform vec2 InSize; + +out vec4 fragColor; + +const vec3 Gray = vec3(0.3, 0.59, 0.11); + +float heat(vec3 c) { + float luma = dot(c, Gray); + float warmth = clamp(c.r - c.b, 0.0, 1.0); + return clamp(luma * 0.7 + warmth * 0.6, 0.0, 1.0); +} + +void main() { + vec2 oneTexel = 1.0 / InSize; + float c = heat(texture(InSampler, texCoord).rgb); + float l = heat(texture(InSampler, texCoord - vec2(oneTexel.x, 0.0)).rgb); + float r = heat(texture(InSampler, texCoord + vec2(oneTexel.x, 0.0)).rgb); + float u = heat(texture(InSampler, texCoord - vec2(0.0, oneTexel.y)).rgb); + float d = heat(texture(InSampler, texCoord + vec2(0.0, oneTexel.y)).rgb); + float edge = clamp(abs(r - l) + abs(d - u), 0.0, 1.0); + + float value = smoothstep(0.08, 0.85, c) + edge * 0.6; + fragColor = vec4(vec3(clamp(value, 0.0, 1.0)), 1.0); +} diff --git a/minecraft-1.21.5/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-1.21.5/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 00000000..339b9729 Binary files /dev/null and b/minecraft-1.21.5/src/main/resources/assets/mcheli/textures/particle/glow.png differ diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-1.21.5/src/main/resources/data/mcheli/damage_type/bullet.json new file mode 100644 index 00000000..92ee6766 --- /dev/null +++ b/minecraft-1.21.5/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-1.21.5/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json b/minecraft-1.21.5/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json new file mode 100644 index 00000000..610afa08 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/chain.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/chain.json new file mode 100644 index 00000000..484f5463 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/container.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/container.json new file mode 100644 index 00000000..4c3bdcd9 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "#c:chests/wooden", + "I": "#c:ingots/iron" + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/drafting_table.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/drafting_table.json new file mode 100644 index 00000000..f3d6324e --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "minecraft:crafting_table", + "F": "#minecraft:wooden_fences", + "P": "#minecraft:planks", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fgm148.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fgm148.json new file mode 100644 index 00000000..c2c61fd6 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fgm148_bullet.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fgm148_bullet.json new file mode 100644 index 00000000..c87b4800 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fim92.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fim92.json new file mode 100644 index 00000000..33a068e1 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fim92_bullet.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fim92_bullet.json new file mode 100644 index 00000000..c472fd6e --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fuel.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fuel.json new file mode 100644 index 00000000..d076b041 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/fuel.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "C": "minecraft:coal", + "I": "#c:ingots/iron" + }, + "pattern": [ + "ICI", + "III" + ], + "result": { + "id": "mcheli:fuel" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/gltd.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/gltd.json new file mode 100644 index 00000000..104ac108 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": "#c:storage_blocks/iron", + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_green.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_green.json new file mode 100644 index 00000000..1046d9cf --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:green_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:item_model": "mcheli:m18_green", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_red.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_red.json new file mode 100644 index 00000000..f98ac51f --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:red_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:item_model": "mcheli:m18_red", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_violet.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_violet.json new file mode 100644 index 00000000..b05f48a7 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:purple_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:item_model": "mcheli:m18_violet", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_yellow.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_yellow.json new file mode 100644 index 00000000..57ecd202 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:yellow_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:item_model": "mcheli:m18_yellow", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/parachute.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/parachute.json new file mode 100644 index 00000000..69eab191 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/parachute.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "S": "#c:strings", + "W": "#minecraft:wool" + }, + "pattern": [ + "WWW", + "S S", + " W " + ], + "result": { + "id": "mcheli:parachute" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/rangefinder.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/rangefinder.json new file mode 100644 index 00000000..58a13385 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json new file mode 100644 index 00000000..d6bac29a --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + "mcheli:rangefinder", + "#c:dusts/redstone" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/uav_station.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/uav_station.json new file mode 100644 index 00000000..24b97d55 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:storage_blocks/redstone" + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/uav_station2.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/uav_station2.json new file mode 100644 index 00000000..74bf0d20 --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/wrench.json b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/wrench.json new file mode 100644 index 00000000..b8c9bcff --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/mcheli/recipe/wrench.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + " I ", + " II", + "I " + ], + "result": { + "id": "mcheli:wrench" + } +} diff --git a/minecraft-1.21.5/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-1.21.5/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json new file mode 100644 index 00000000..1042db9e --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "mcheli:bullet" + ] +} diff --git a/minecraft-1.21.5/src/main/resources/mcheli_logo.png b/minecraft-1.21.5/src/main/resources/mcheli_logo.png new file mode 100644 index 00000000..95c72198 Binary files /dev/null and b/minecraft-1.21.5/src/main/resources/mcheli_logo.png differ diff --git a/minecraft-1.21.5/src/main/resources/pack.mcmeta b/minecraft-1.21.5/src/main/resources/pack.mcmeta new file mode 100644 index 00000000..066331ae --- /dev/null +++ b/minecraft-1.21.5/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "${mod_name} resources", + "pack_format": 55, + "supported_formats": [55, 71] + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 00000000..ff258847 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 00000000..2cf2f6a8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; +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.block.DispenserBlock; +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() { + } + + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + + /** + * 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. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(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.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java new file mode 100644 index 00000000..d3bfdee5 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,187 @@ +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 com.jnhprojects.mcheli.content.pack.PendingPacks; +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.HashSet; +import java.util.List; +import java.util.Set; +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 static final String SOUNDS = "assets/mcheli/sounds/"; + + private final Path folder; + private final PendingPacks pending; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); + this.legacy = legacy; + } + + /** {@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); + PendingPacks.cleanReplaced(folder); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + sounds = soundsOf(accepted); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + + private static @Nullable String refusal(ContentPack pack, long size, long total) { + if (!ServerConfig.isPackAllowed(pack.id())) { + return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; + } + 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.1/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 00000000..7846b7bf --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/LegacyContent.java new file mode 100644 index 00000000..495bb502 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -0,0 +1,239 @@ +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.List; +import java.util.Locale; +import java.util.Optional; +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.MODERN; + + private final Path folder; + private final Path packRoot; + private @Nullable Component problem; + + 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); + } + + /** 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-26.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 00000000..1a890651 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; +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.ModMenus; +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); + ModBlocks.init(); + ModBlockEntities.init(); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModMenus.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-26.1/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 00000000..25a3aea8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,262 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * {@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
+ *   /mcheli give <players> <vehicle> [count]   (operators)
+ *   /mcheli reload                       re-read the definitions and packs (operators)
+ *   /mcheli pending                      packs players have sent, waiting (operators)
+ *   /mcheli approve <pack>               put a waiting pack in use (operators)
+ *   /mcheli reject <pack>                throw a waiting pack away (operators)
+ * 
+ */ +public final class McheliCommand { + private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); + private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); + + private McheliCommand() { + } + + 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(Commands.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(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::reload)) + .then(Commands.literal("pending") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + } + + private static int status(CommandContext ctx) { + 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); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + + 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-26.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java new file mode 100644 index 00000000..ed6eeb7e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -0,0 +1,64 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.core.Holder; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +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. + * + *

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. + */ +public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + + private static final Map EVENTS = new ConcurrentHashMap<>(); + + private ModSounds() { + } + + public static SoundEvent get(String name) { + return EVENTS.computeIfAbsent(name, n -> SoundEvent.createVariableRangeEvent(Identifier.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) { + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); + } + + /** Plays {@code name} at a position for everyone nearby. Call on the server. */ + public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } + } + + /** Plays {@code name} for one player only, such as a hit confirmation. */ + public static void playTo(ServerPlayer player, String name, float volume, float pitch) { + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java new file mode 100644 index 00000000..9db31738 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,172 @@ +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.BooleanValue INFINITE_FUEL; + 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; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; + + 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()); + INFINITE_FUEL = builder + .comment("Whether vehicles never run out of fuel. Original: InfinityFuel") + .define("infiniteFuel", false); + 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); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); + builder.pop(); + + SPEC = builder.build(); + } + + 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(); + } + + public static boolean infiniteFuel() { + return SPEC.isLoaded() ? INFINITE_FUEL.get() : INFINITE_FUEL.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 allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 00000000..8545d2b5 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.block; + +import com.mojang.serialization.MapCodec; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + private static final MapCodec CODEC = simpleCodec(DraftingTableBlock::new); + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.column(16.0, 0.0, 15.0); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 00000000..56238f3f --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 00000000..ab62e120 --- /dev/null +++ b/minecraft-26.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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 00000000..70bcc23e --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 00000000..bf19ca3c --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,333 @@ +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.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ParachuteRenderer; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; +import com.jnhprojects.mcheli.client.screen.VehicleScreen; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; +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.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.network.chat.Component; +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 net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; + +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 static boolean shaderProgramsAssigned; + + private ClientHooks() { + } + + /** Loads a post effect into the game renderer, whose own method is private on 26.1. */ + public interface PostEffects { + void load(Identifier effect); + } + + private static PostEffects postEffects = effect -> { + }; + + static PostEffects postEffects() { + return postEffects; + } + + /** During mod construction, on a client only. */ + public static void init(PostEffects effects) { + postEffects = effects; + 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); + renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, + BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); + } + + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + + /** On the main thread during client setup, once our menu types are registered. */ + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::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() { + if (!shaderProgramsAssigned) { + // On the first tick rather than at construction, so Iris has surely loaded by now. + shaderProgramsAssigned = true; + Glow.assignShaderPrograms(); + } + CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); + VehicleCamera.tick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + TargetMarkers.tick(); + WorkshopClient.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.setScreen(new TeamManagerScreen()); + } else { + mc.player.sendOverlayMessage(Component.translatable("message.mcheli.team.operators_only")); + } + } + + /** 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); + GltdController.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; + } + } + + /** The field of view for this frame, narrowed while the player zooms through a sight. */ + public static float fov(float fov) { + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); + } + + /** 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() || GltdController.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(); + } + + /** The partial tick the camera entity is drawn at, for the loaders whose event does not carry it. */ + public static float cameraPartialTicks() { + Minecraft mc = Minecraft.getInstance(); + return mc.gameRenderer.getMainCamera().getCameraEntityPartialTicks(mc.getDeltaTracker()); + } + + 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); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.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(); + WorkshopClient.loggedOut(); + Markers.clear(); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java new file mode 100644 index 00000000..d933e518 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,338 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +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 com.jnhprojects.mcheli.network.PackSoundPacket; +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. + */ +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + 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(); + } + } + + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + 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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/CrewController.java new file mode 100644 index 00000000..32af6770 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -0,0 +1,482 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +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.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Input; +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; + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } + 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; + VehicleCamera.reset(); + } + + Input keys = player.input.keyPresses; + boolean jumpPressed = keys.jump() && !jumpWasDown; + jumpWasDown = keys.jump(); + boolean pilot = seat == 0 && vehicle.getControllingPassenger() == player; + + if (pilot) { + AircraftEntity.ControlInput input = vehicle.input(); + input.throttleUp = keys.forward(); + input.throttleDown = keys.backward(); + input.left = keys.left(); + input.right = keys.right(); + input.freeLook = isFreeLook(vehicle); + input.brake = vehicle instanceof TankEntity && keys.jump(); + 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.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } + 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 (looksThroughSight(vehicle, seat)) { + VehicleCamera.onZoomKey(player, vehicle); + } else if (pilot) { + send(vehicle, CrewActionPacket.Action.FOLD); + } + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } + } + while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { + if (!vehicle.isDestroyed()) { + send(vehicle, CrewActionPacket.Action.VEHICLE_SCREEN); + } + } + 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); + } + } + } + + /** + * Whether this seat sees through a sight that can zoom: a camera position or gunner sight, the + * pilot in gunner mode, or the pilot of a UAV. The zoom key folds the wings everywhere else. + */ + private static boolean looksThroughSight(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount() || def.cameraZoom <= 1) { + return false; + } + return eyePosition(vehicle, def, seat) != null || vehicle.isGunnerMode() || def.uav; + } + + 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; + } + + 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-26.1/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 00000000..257c0115 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.screen == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.text(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.text(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.text(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java new file mode 100644 index 00000000..111759ac --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +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 KeyMapping.Category CATEGORY = KeyMapping.Category.register(Identifier.fromNamespaceAndPath(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); + /** Zooms through a sight, as the original Z key did; otherwise 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); + /** Fuel and parachute slots, reloading and the cargo chest. The original used R, which switches seats here. */ + public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); + /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ + public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, 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, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); + + private KeyBindings() { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 00000000..5a57fc58 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,238 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.screen == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.text(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(RenderPipelines.GUI_TEXTURED, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), + (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.text(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.text(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.text(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.text(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.text(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.text(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 00000000..80aafc99 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,155 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final Identifier SCOPE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(RenderPipelines.GUI_TEXTURED, SCOPE, (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushMatrix(); + g.pose().translate(w / 2.0F, h / 2.0F); + g.pose().scale(k, k); + drawReadouts(g, mc.font, player, stack); + g.pose().popMatrix(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphicsExtractor g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.text(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.text(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.text(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.text(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.text(font, ">", 30, y, WHITE); + } + g.text(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 00000000..7f7afd84 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,165 @@ +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.Identifier; +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(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + 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); + } + + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(Identifier id) throws IOException { + Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); + 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 Identifier texture(Identifier 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); + } + } + + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(Identifier id) { + TEXTURES.put(id, upload(id)); + } + + private static boolean upload(Identifier id) { + try (InputStream in = open(id)) { + if (in == null) { + return false; + } + NativeImage image = NativeImage.read(in); + Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, 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) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } + for (Path root : roots) { + Path file = inRoot(root, path); + if (file != null) { + return file; + } + } + return null; + } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 00000000..eabf0bba --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,87 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3fc; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta.getGameTimeDeltaPartialTick(false); + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "â–¼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "â—†", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphicsExtractor g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.getMainCamera(); + Vector3fc forward = camera.forwardVector(); + Vec3 offset = at.subtract(camera.position()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + Vec3 ndc = mc.gameRenderer.projectPointToScreen(at); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.centeredText(font, symbol, (int) x, (int) y - 4, color); + g.centeredText(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.centeredText(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.centeredText(font, "â—†", ex, ey - 4, color); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 00000000..de7ef3c4 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,127 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +import java.util.function.Consumer; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision and thermal view for those with {@code EnableNightVision}. Both + * reset whenever the player changes seat or leaves the vehicle. + */ +public final class VehicleCamera { + /** The original's camera modes; the HUD reads the ordinal as {@code cam_mode}. */ + public enum Mode { + NORMAL, + NIGHT_VISION, + /** Night vision seen through a white hot filter, the original's "pencil" shader mode. */ + THERMAL + } + + /** The post effect of the thermal view, {@code assets/mcheli/post_effect/thermal.json}. */ + private static final Identifier THERMAL_EFFECT = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "thermal"); + + private static float zoom = 1.0F; + private static Mode mode = Mode.NORMAL; + + private VehicleCamera() { + } + + public static float zoom() { + return zoom; + } + + public static Mode mode() { + return mode; + } + + /** Back to the plain view, on a seat change or after leaving the vehicle. */ + static void reset() { + zoom = 1.0F; + mode = Mode.NORMAL; + } + + /** + * Keeps the thermal filter in the player's post effects while the thermal view is on. The list + * is the one a server's post effect packet fills, so ours is put back if that replaces it. + */ + static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + boolean active = THERMAL_EFFECT.equals(mc.gameRenderer.currentPostEffect()); + if (mode == Mode.THERMAL && !active) { + ClientHooks.postEffects().load(THERMAL_EFFECT); + } else if (mode != Mode.THERMAL && active) { + mc.gameRenderer.clearPostEffect(); + } + } + + /** + * {@code zoomCamera}: each press doubles the zoom up to the vehicle's {@code CameraZoom}, and + * the press after that goes back to one. The caller checks that the seat looks through a sight. + */ + static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { + refuse(player); + return; + } + zoom = zoom >= max - 0.01F ? 1.0F : Math.min(zoom * 2.0F, max); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + + /** + * {@code switchCameraMode}: steps from the plain view to night vision, to thermal view and + * back. The server applies night vision as an effect in both of the last two; the thermal + * filter is drawn here. + */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { + Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; + if (mode == Mode.NORMAL || next == Mode.NORMAL) { + nightVision.accept(next != Mode.NORMAL); + } + mode = next; + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + + /** The view's field of view, narrowed by the zoom. */ + public static float fov(float fov) { + return zoom > 1.0F ? fov / zoom : fov; + } + + /** {@code playSoundNG}: the key does nothing in this seat. */ + private static void refuse(LocalPlayer player) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java new file mode 100644 index 00000000..9507412f --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java new file mode 100644 index 00000000..c0ab2443 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -0,0 +1,187 @@ +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(); + } + } + + 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().position() : 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-26.1/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java new file mode 100644 index 00000000..c742003d --- /dev/null +++ b/minecraft-26.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.GuiGraphicsExtractor; +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(GuiGraphicsExtractor 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.text(font, Component.translatable("hud.mcheli.hp", vehicle.hp(), def.maxHp), x, y, hpColor); + y += 11; + + WeaponSet set = weapons.current(seat); + if (set != null) { + g.text(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.text(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.VEHICLE_SCREEN.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.text(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.text(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.text(font, flare, x, y, vehicle.isFlareActive() ? GREEN : WHITE); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java new file mode 100644 index 00000000..d45c753b --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java @@ -0,0 +1,165 @@ +package com.jnhprojects.mcheli.client.hud; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import org.joml.Matrix3x2fStack; + +/** + * The drawing primitives of {@code MCH_HudItem}, on the 26.x 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 GuiGraphicsExtractor g; + private final Font font; + + HudCanvas(GuiGraphicsExtractor g, Font font) { + this.g = g; + this.font = font; + } + + Matrix3x2fStack 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) { + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) x, (float) y); + pose.rotate((float) Math.toRadians(degrees)); + pose.translate((float) -x, (float) -y); + } + + void pop() { + g.pose().popMatrix(); + } + + 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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) x0, (float) y0); + pose.rotate((float) Math.atan2(dy, dx)); + pose.translate(0.0F, -LINE_WIDTH / 2.0F); + pose.scale((float) length, LINE_WIDTH); + g.fill(0, 0, 1, 1, color); + pose.popMatrix(); + } + + /** {@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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) left, (float) top); + pose.scale((float) width, (float) height); + g.fill(0, 0, 1, 1, color); + pose.popMatrix(); + } + + 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(Identifier 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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) (left + width / 2.0), (float) (top + height / 2.0)); + pose.rotate((float) Math.toRadians(rotation)); + pose.translate((float) (-width / 2.0), (float) (-height / 2.0)); + pose.scale((float) width, (float) height); + g.blit(texture, 0, 0, 1, 1, + (float) (u / textureWidth), (float) ((u + uWidth) / textureWidth), + (float) (v / textureHeight), (float) ((v + vHeight) / textureHeight)); + pose.popMatrix(); + } + + void text(String text, int x, int y, int color, boolean centered) { + if (centered) { + g.centeredText(font, text, x, y, color); + } else { + g.text(font, text, x, y, color); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java new file mode 100644 index 00000000..0115b4c3 --- /dev/null +++ b/minecraft-26.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.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.Identifier; +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(GuiGraphicsExtractor 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 -> { + Identifier texture = Identifier.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-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java new file mode 100644 index 00000000..6e611a23 --- /dev/null +++ b/minecraft-26.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.Identifier; +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, Identifier 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-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java new file mode 100644 index 00000000..cecf5018 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -0,0 +1,373 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.client.VehicleCamera; +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 read as their idle state for now: 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; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; + 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; + private int fuelWarnCount; + + /** 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.getMinY()), mob.getBlockZ()); + int blocks = 0; + for (; pos.getY() < level.getMaxY(); 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.getMinY(); 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().getOverworldClockTime(), 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, vehicle.fuelRatio()); + v.set(HudVariable.LOW_FUEL, lowFuel(vehicle)); + 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, VehicleCamera.mode().ordinal()); + v.set(HudVariable.CAM_ZOOM, VehicleCamera.zoom()); + 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); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); + } + + /** + * {@code isLowFuel}: below 10% the warning blinks, on for eight seconds out of every fourteen. + * Ticks with each update, as the original counted per frame. + */ + private boolean lowFuel(AircraftEntity vehicle) { + if (fuelWarnCount <= 0) { + fuelWarnCount = 280; + } + fuelWarnCount--; + return fuelWarnCount < 160 && vehicle.maxFuel() > 0 && vehicle.fuelRatio() < 0.1F; + } + + /** 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); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; + } else { + vars.set(HudVariable.IS_HEAT_WPN, 0.0); + vars.set(HudVariable.RELOADING, 0.0); + vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; + } + 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, mortarDistance); + } + + 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().getOverworldClockTime() + 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 -> mortarDistance; + case MC_VER -> "26.1"; + 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 -> VehicleCamera.zoom(); + case UAV_DIST -> Math.max(vehicle.stationDistance(), 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-26.1/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 00000000..a09d9b2e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java @@ -0,0 +1,103 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +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.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.level.QuadParticleRenderState; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +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 SingleQuadParticle { + 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, 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; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.0F; + } + + @Override + public void tick() { + super.tick(); + if (flicker) { + waver = 0.7F + random.nextFloat() * 0.3F; + } + } + + @Override + public void extract(QuadParticleRenderState state, 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.extract(state, 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 getLightCoords(float partialTicks) { + return LightCoordsUtil.FULL_BRIGHT; + } + + @Override + protected Layer getLayer() { + 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, RandomSource random) { + 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.first(), strength); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 00000000..ff6c3e73 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.level.QuadParticleRenderState; +import net.minecraft.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends SingleQuadParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z, sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void extract(QuadParticleRenderState state, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.extract(state, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + protected Layer getLayer() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd, RandomSource random) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, random); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java new file mode 100644 index 00000000..938be6d9 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java @@ -0,0 +1,59 @@ +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** The close air support A-10, {@code MCH_RenderA10}. */ +public class A10Renderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/bullets/a10.png"); + + public A10Renderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 10.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + boolean visible; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(A10Entity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = entity.isVisible(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.get("", "a-10") : null; + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(-state.pitch)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java new file mode 100644 index 00000000..d2dd770d --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -0,0 +1,77 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import org.jspecify.annotations.Nullable; + +/** + * Snapshot of a vehicle for one frame, filled in by {@link AircraftRenderer#extractRenderState}. + * Every angle is already interpolated for the frame's partial tick. + */ +public class AircraftRenderState extends EntityRenderState { + /** 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; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; + public float throttle; + + // Part states, 0 to 90 degrees + public float canopy; + public float landingGear; + public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; + 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; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; + + /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ + public WeaponPartState[] weaponParts = new WeaponPartState[0]; + /** 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-26.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java new file mode 100644 index 00000000..2445f269 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -0,0 +1,342 @@ +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.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; +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.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.debug.DebugScreenEntries; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.gizmos.GizmoStyle; +import net.minecraft.gizmos.Gizmos; +import net.minecraft.resources.Identifier; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * 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 { + private static final int HULL_COLOR = 0xFF00E0FF; + private static final int GENERATED_COLOR = 0xFFFFC000; + /** Ropes take the colour of brown wool. */ + private static final Identifier ROPE = Identifier.withDefaultNamespace("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; + + public AircraftRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 2.0F; + } + + /** + * With F3+B on, outlines the hull boxes next to vanilla's main box: what projectiles hit and + * what other vehicles collide with. The definition's own boxes are cyan, the ones generated + * from the model amber. + */ + private static void showHull(AircraftEntity entity, AircraftDefinition def, float partialTicks) { + List hull = entity.hullBoxes(); + if (hull.size() < 2) { + return; + } + Vec3 offset = entity.getPosition(partialTicks).subtract(entity.position()); + int listed = def.boundingBoxes.size(); + try (Gizmos.TemporaryCollection ignored = Minecraft.getInstance().levelRenderer.collectPerFrameGizmos()) { + // The first is the main box, which vanilla draws itself; then the listed boxes, then the generated ones. + for (int i = 1; i < hull.size(); i++) { + Gizmos.cuboid(hull.get(i).move(offset), GizmoStyle.stroke(i <= listed ? HULL_COLOR : GENERATED_COLOR)); + } + } + } + + @Override + public AircraftRenderState createRenderState() { + return new AircraftRenderState(); + } + + @Override + public void extractRenderState(T entity, AircraftRenderState state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + 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; + } + if (Minecraft.getInstance().debugEntries.isCurrentlyEnabled(DebugScreenEntries.ENTITY_HITBOXES)) { + showHull(entity, def, partialTicks); + } + + 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 HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getXRot(partialTicks) : state.pitch; + } + 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(LightCoordsUtil.sky(lightCoords) - entity.level().getSkyDarken(), 0); + int light = Math.max(LightCoordsUtil.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 + protected AABB getBoundingBoxForCulling(T entity) { + // The hit box is far smaller than most models, so cull on a generous box instead. + 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 submit(AircraftRenderState state, PoseStack poseStack, SubmitNodeCollector collector, CameraRenderState camera) { + 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, collector, texture(def.folder(), state.texture), state.lightCoords, + OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, collector); + } + if (state.searchLight) { + searchLights(def, state, poseStack, collector); + } + poseStack.popPose(); + } + super.submit(state, poseStack, collector, camera); + } + + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.mulPose(Axis.YN.rotationDegrees(state.searchLightYaw - state.yaw + light.yaw())); + pose.mulPose(Axis.XP.rotationDegrees(state.searchLightPitch + 90.0F - state.pitch + light.pitch())); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.mulPose(Axis.YN.rotationDegrees(light.yaw() + steer)); + pose.mulPose(Axis.XP.rotationDegrees(90.0F + light.pitch())); + } + Glow.cone(pose, collector, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + int light = state.lightCoords; + float length = state.ropeLength; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(ROPE), (p, buffer) -> { + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + }); + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.addVertex(p, x, y, z).setColor(0xFFFFFFFF).setUv(u, v).setOverlay(OverlayTexture.NO_OVERLAY).setLight(light).setNormal(p, 0.0F, 1.0F, 0.0F); + } + + private static Identifier texture(String folder, String name) { + return RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 00000000..5d7f43b7 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + /** Towed end, relative to the chain entity. */ + Vec3 from = Vec3.ZERO; + /** From the towed end to the end that pulls. */ + Vec3 span = Vec3.ZERO; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + protected AABB getBoundingBoxForCulling(ChainEntity chain) { + AABB box = super.getBoundingBoxForCulling(chain); + Entity towed = chain.towed(); + return towed != null ? box.minmax(towed.getBoundingBox()) : box; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ChainEntity chain, State state, float partialTicks) { + super.extractRenderState(chain, state, partialTicks); + Entity tow = chain.tow(); + Entity towed = chain.towed(); + if (tow == null || towed == null) { + state.span = Vec3.ZERO; + return; + } + Vec3 here = chain.getPosition(partialTicks); + Vec3 start = towed.getPosition(partialTicks); + state.from = start.subtract(here); + state.span = tow.getPosition(partialTicks).subtract(start); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + double length = state.span.length(); + if (mesh != null && length > LINK) { + Vec3 step = state.span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + int light = state.lightCoords; + pose.pushPose(); + pose.translate(state.from); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-yaw)); + pose.mulPose(Axis.ZP.rotationDegrees(pitch)); + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, GREY)); + pose.popPose(); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 00000000..665893c1 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + public static class State extends BlockEntityRenderState { + int rotation; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(DraftingTableBlockEntity table, State state, float partialTicks, Vec3 cameraPosition, + ModelFeatureRenderer.@Nullable CrumblingOverlay breakProgress) { + BlockEntityRenderer.super.extractRenderState(table, state, partialTicks, cameraPosition, breakProgress); + state.rotation = table.getBlockState().getValue(DraftingTableBlock.ROTATION); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.mulPose(Axis.YP.rotationDegrees(-state.rotation * 45.0F + 180.0F)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + pose.popPose(); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 00000000..80ca1e3b --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final Identifier texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(T entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.pitch)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, tint)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 00000000..66cffb45 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java @@ -0,0 +1,50 @@ +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.world.phys.Vec3; + +/** + * 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; + } + + public static class State extends EntityRenderState { + float glow; + float flicker = 1.0F; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(FlareEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.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. + state.flicker = 0.8F + 0.2F * (float) Math.sin((entity.tickCount + partialTicks) * 1.7F + entity.getId()); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * state.flicker, GlowParticleOptions.FIRE, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * 0.35F, GlowParticleOptions.FLASH, state.glow); + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java new file mode 100644 index 00000000..d2a46857 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.resources.Identifier; +import org.slf4j.Logger; +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 final Map, Map, List>> buckets = new IdentityHashMap<>(); + + private GlassSplit() { + } + + /** Called from the render thread only. */ + Split split(List groups, Identifier texture) { + if (groups.isEmpty()) { + return Split.EMPTY; + } + return splits.computeIfAbsent(groups, g -> new HashMap<>()).computeIfAbsent(texture, t -> compute(groups, t)); + } + + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + + void clear() { + textures.clear(); + splits.clear(); + buckets.clear(); + } + + private Split compute(List groups, Identifier texture) { + 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(Identifier 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.getPixel(x, y) >>> 24); + } + } + return new Alpha(w, h, values); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 00000000..230d0f30 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,211 @@ +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.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.ColorTargetState; +import com.mojang.blaze3d.pipeline.DepthStencilState; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.platform.CompareOp; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; + +/** + * 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. + * With order independent transparency on, the vanilla translucent pipelines stand in. + */ +public final class Glow { + /** A white disc fading to nothing at the rim; also the {@code mcheli:glow} particle sprite. */ + public static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/particle/glow.png"); + + private static final DepthStencilState TEST_ONLY = new DepthStencilState(CompareOp.LESS_THAN_OR_EQUAL, false); + + private static final RenderPipeline PARTICLE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow_particle")) + .withColorTargetState(new ColorTargetState(BlendFunction.LIGHTNING)) + .withDepthStencilState(TEST_ONLY) + .build(); + + /** + * Vanilla's translucent particles with depth writes off. A puff's clear edge would otherwise + * hide the puffs drawn after it, and a smoke cloud is mostly overlapping puffs. + */ + private static final RenderPipeline SMOKE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/smoke_particle")) + .withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)) + .withDepthStencilState(TEST_ONLY) + .build(); + + private static final RenderPipeline ENTITY_PIPELINE = RenderPipeline.builder(RenderPipelines.MATRICES_FOG_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow")) + .withVertexShader("core/entity") + .withFragmentShader("core/entity") + .withShaderDefine("EMISSIVE") + .withShaderDefine("NO_OVERLAY") + .withShaderDefine("NO_CARDINAL_LIGHTING") + .withSampler("Sampler0") + .withVertexFormat(DefaultVertexFormat.ENTITY, VertexFormat.Mode.QUADS) + .withColorTargetState(new ColorTargetState(BlendFunction.LIGHTNING)) + .withDepthStencilState(TEST_ONLY) + .build(); + + /** The particle layer for {@code mcheli:glow}. */ + @SuppressWarnings("deprecation") // Vanilla's own particle layers still name the atlas this way. + public static final SingleQuadParticle.Layer PARTICLE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, PARTICLE_PIPELINE); + + /** The particle layer for {@code mcheli:smoke}. */ + @SuppressWarnings("deprecation") + public static final SingleQuadParticle.Layer SMOKE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, SMOKE_PIPELINE); + + private static final RenderType HALO = RenderType.create(MCHeli.MOD_ID + "_glow", RenderSetup.builder(ENTITY_PIPELINE) + .withTexture("Sampler0", TEXTURE) + .sortOnUpload() + .createRenderSetup()); + + private static final int FULL_BRIGHT = LightCoordsUtil.FULL_BRIGHT; + + private Glow() { + } + + /** + * Hands our pipelines to Iris, so a shader pack draws them with a matching program: the halos + * and tracers as emissive entities, the glow and smoke particles as translucent particles. + * Called once the mods are loaded; without Iris it does nothing. + */ + public static void assignShaderPrograms() { + IrisCompat.assign(ENTITY_PIPELINE, "EMISSIVE_ENTITIES"); + IrisCompat.assign(PARTICLE_PIPELINE, "PARTICLES_TRANSLUCENT"); + IrisCompat.assign(SMOKE_PIPELINE, "PARTICLES_TRANSLUCENT"); + } + + /** + * 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 color RGB; {@code strength} becomes the alpha, which scales the light added + */ + public static void halo(PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera, Vec3 offset, float radius, int color, + float strength) { + if (strength <= 0.0F || radius <= 0.0F) { + return; + } + int argb = ARGB.color(alpha(strength), color); + pose.pushPose(); + pose.translate(offset); + pose.mulPose(camera.orientation); + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // 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, SubmitNodeCollector collector, 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 = ARGB.color(alpha(strength), color); + int end = ARGB.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; + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // 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); + }); + } + + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, SubmitNodeCollector collector, float height, float radius, int start, int end) { + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + }); + } + + 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); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 00000000..02cc82ba --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +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.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float headYaw; + float headPitch; + boolean drawHead = true; + boolean laser; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(GltdEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + state.laser = viewer != null; + state.drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + state.headYaw = viewer != null ? viewer.getYRot(partialTicks) : state.yaw; + state.headPitch = viewer != null ? viewer.getXRot(partialTicks) : 0.0F; + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + int light = state.lightCoords; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + draw(mesh.part("$body"), pose, collector, light); + pose.popPose(); + if (state.drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.headYaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.headPitch)); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, collector, light); + pose.popPose(); + } + } + if (state.laser) { + float yaw = state.headYaw * Mth.DEG_TO_RAD; + float pitch = state.headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = new Vec3(state.x, state.y, state.z).add(head).subtract(camera.pos); + pose.pushPose(); + pose.translate(head); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, collector, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static void draw(List groups, PoseStack pose, SubmitNodeCollector collector, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java new file mode 100644 index 00000000..3803102e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java @@ -0,0 +1,49 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.pipeline.RenderPipeline; + +import java.lang.reflect.Method; + +/** + * Tells Iris which shader program draws our own render pipelines. Iris only knows the vanilla + * pipelines and draws any other with a program whose vertex layout does not match, which throws + * the vertices far off: the glows turn into long coloured lines running to the horizon. Iris is + * optional, so its API is looked up by reflection instead of compiled against. + */ +final class IrisCompat { + private static final String API = "net.irisshaders.iris.api.v0.IrisApi"; + private static final String PROGRAM = "net.irisshaders.iris.api.v0.IrisProgram"; + + private IrisCompat() { + } + + /** + * Assigns {@code pipeline} to the Iris program named {@code program}, such as + * {@code EMISSIVE_ENTITIES}. Does nothing without Iris. + * + * @return whether Iris took it + */ + static boolean assign(RenderPipeline pipeline, String program) { + Class api; + try { + api = Class.forName(API); + } catch (ClassNotFoundException absent) { + MCHeli.LOGGER.debug("Iris is not installed, {} keeps its own shader", pipeline.getLocation()); + return false; + } + try { + Class programs = Class.forName(PROGRAM); + Object instance = api.getMethod("getInstance").invoke(null); + Object target = programs.getField(program).get(null); + Method assign = api.getMethod("assignPipeline", RenderPipeline.class, programs); + assign.invoke(instance, pipeline, target); + MCHeli.LOGGER.debug("Iris draws {} with its {} program", pipeline.getLocation(), program); + return true; + } catch (ReflectiveOperationException | LinkageError e) { + // An Iris without this API: glows may look wrong under a shader pack, nothing worse. + MCHeli.LOGGER.warn("Could not hand {} to Iris, glows may look wrong with shaders: {}", pipeline.getLocation(), e.toString()); + return false; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java new file mode 100644 index 00000000..57dad7e5 --- /dev/null +++ b/minecraft-26.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.Identifier; +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) { + Identifier id = Identifier.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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java new file mode 100644 index 00000000..47cc66e3 --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 00000000..15ff27a2 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * {@code MCH_RenderParachute}: the item's round canopy, or the ejection seat with its canopy, + * from the models the import brought in. + */ +public class ParachuteRenderer extends EntityRenderer { + private static final Identifier ROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute1.png"); + private static final Identifier SEAT = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute2.png"); + + public ParachuteRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + ParachuteEntity.Kind kind = ParachuteEntity.Kind.ITEM; + boolean open; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ParachuteEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.open = entity.isOpen(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + List groups = groups(state); + if (!groups.isEmpty()) { + Identifier texture = state.kind == ParachuteEntity.Kind.ITEM ? ROUND : SEAT; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static List groups(State state) { + if (state.kind == ParachuteEntity.Kind.ITEM) { + Mesh mesh = MeshCache.INSTANCE.get("", "parachute1"); + return mesh != null ? mesh.groups() : List.of(); + } + Mesh mesh = MeshCache.INSTANCE.get("", "parachute2"); + boolean canopy = state.kind == ParachuteEntity.Kind.DROP || state.open; + return mesh != null ? mesh.part(canopy ? "$parachute" : "$seat") : List.of(); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java new file mode 100644 index 00000000..d6eefbe0 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -0,0 +1,554 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +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.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.resources.Identifier; +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 SubmitNodeCollector collector; + private final Identifier 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, SubmitNodeCollector collector, + Identifier texture, int light, int overlay, int glassColor) { + this.def = def; + this.mesh = mesh; + this.state = state; + this.pose = pose; + this.collector = collector; + 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 = RenderTypes.entityCutoutCull(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 = RenderTypes.entityTranslucentEmissive(texture); + this.light = light; + this.overlay = overlay; + this.glassColor = glassColor; + } + + void submitAll() { + switch (def) { + case PlaneDefinition plane -> { + nozzles(plane); + wings(plane); + propellers(plane); + hull(); + } + case HelicopterDefinition heli -> { + hull(); + rotors(heli); + } + case VehicleDefinition vehicle -> { + hull(); + for (VehiclePart part : vehicle.parts) { + vehiclePart(vehicle, part); + } + } + default -> hull(); + } + weapons(); + rotatingParts(); + movingParts(def.hatches, state.hatch); + trackRollers(); + crawlerTracks(); + steeringWheels(); + wheels(); + throttles(); + cameras(); + landingGear(); + weaponBays(); + movingParts(def.canopies, state.canopy); + } + + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + + // Kind specific parts + + private void rotors(HelicopterDefinition heli) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); + 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); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : 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; + } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + Identifier own = RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderTypes.entityCutoutCull(own), RenderTypes.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, Identifier texture, RenderType opaqueType, RenderType glassType) { + GlassSplit.Split split = GlassSplit.INSTANCE.split(groups, texture); + if (!split.opaque().isEmpty()) { + collector.submitCustomGeometry(pose, opaqueType, (p, buffer) -> MeshRenderer.emit(split.opaque(), p, buffer, light, overlay)); + } + if (!split.glass().isEmpty()) { + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + collector.submitCustomGeometry(pose, glassType, state.inGui + ? (p, buffer) -> MeshRenderer.emit(split.glass(), p, buffer, light, overlay, glassColor) + : (p, buffer) -> MeshRenderer.emitFrontFaces(split.glass(), p, buffer, light, overlay, glassColor)); + } + } + + 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-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java new file mode 100644 index 00000000..a780333e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -0,0 +1,181 @@ +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.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Util; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import java.util.function.Function; + +/** + * 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 { + /** Vanilla on 26.1 only offers this one drawn into the item target. */ + private static final Function TRANSLUCENT_CULL = Util.memoize(texture -> RenderType.create( + MCHeli.MOD_ID + "_translucent_cull", RenderSetup.builder(RenderPipelines.ENTITY_TRANSLUCENT_CULL) + .withTexture("Sampler0", texture) + .useLightmap() + .useOverlay() + .affectsCrumbling() + .sortOnUpload() + .setOutline(RenderSetup.OutlineProperty.AFFECTS_OUTLINE) + .createRenderSetup())); + 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 + } + + public static class State extends EntityRenderState { + 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; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ProjectileEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.model = entity.model(); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = !entity.isSpent() && (!(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 || entity.isSpent() ? 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 submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.find("bullets", state.model) : null; + if (mesh != null) { + Identifier texture = Identifier.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)); + int color = state.color; + collector.submitCustomGeometry(pose, TRANSLUCENT_CULL.apply(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, FULL_BRIGHT, OverlayTexture.NO_OVERLAY, color)); + pose.popPose(); + } + if (state.visible) { + submitGlow(state, pose, collector, camera); + } + super.submit(state, pose, collector, camera); + } + + private static void submitGlow(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + 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 toCamera = new Vec3(state.x - camera.pos.x, state.y - camera.pos.y, state.z - camera.pos.z); + Glow.streak(pose, collector, toCamera, back.scale(Math.min(speed, MAX_TRACER)), 0.08F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, 0.35F, state.glowColor, state.glow); + } + case MOTOR -> { + Vec3 nozzle = back.scale(MOTOR_OFFSET); + Glow.halo(pose, collector, camera, nozzle, 1.1F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, nozzle, 0.4F, GlowParticleOptions.FLASH, state.glow); + } + default -> { + } + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java new file mode 100644 index 00000000..9ddb1690 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -0,0 +1,49 @@ +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.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 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(LivingEntityRenderState renderState, PoseStack pose, float partialTicks) { + // Render states carry no entity reference; players are the ones that keep their id. + if (!(renderState instanceof AvatarRenderState state)) { + return; + } + ClientLevel level = Minecraft.getInstance().level; + Entity rider = level != null ? level.getEntity(state.id) : null; + if (rider == null || !(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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 00000000..a693052b --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,55 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + String name = ""; + float yaw; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ThrowableEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.name = entity.definitionName(); + state.yaw = entity.getYRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", state.name); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/throwable/" + state.name + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 00000000..e3f7f9c7 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + public static class State extends EntityRenderState { + float yaw; + int kind; + boolean lit; + float cover; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(UavStationEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.cover = entity.cover(partialTicks); + state.lit = state.kind == UavStationEntity.PORTABLE ? state.cover > 0.95F : entity.isLinked(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + int index = state.kind == UavStationEntity.PORTABLE ? 1 : 0; + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[index] + (state.lit ? "_on" : "") + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + if (index == 0) { + draw(pose, collector, texture, mesh.groups(), light); + } else { + draw(pose, collector, texture, mesh.part("$body"), light); + folding(pose, collector, texture, mesh.part("$cover"), state.cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, collector, texture, mesh.part("$laptop_cover"), state.cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, collector, texture, mesh.part("$display"), state.cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List part, + float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.mulPose(Axis.XN.rotationDegrees(angle)); + pose.translate(0.0F, -y, -z); + draw(pose, collector, texture, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List groups, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 00000000..e70d6bd0 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,335 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.recipebook.RecipeCollection; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedItemContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.RecipeDisplay; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the player's recipe book, which holds every MCHeli recipe from login. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 400, 240); + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); + selectList(list); + } + + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.setScreen(new WorkshopHomeScreen()); + } + + /** Every MCHeli recipe the recipe book holds, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + for (RecipeCollection collection : minecraft.player.getRecipeBook().getCollections()) { + for (RecipeDisplayEntry entry : collection.getRecipes()) { + ItemStack result = entry.display().result().resolveForFirstStack(context); + if (!result.isEmpty() && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(entry); + } + } + } + for (List entries : lists) { + entries.sort(Comparator.comparing(e -> e.display().result().resolveForFirstStack(context).getHoverName().getString())); + } + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeDisplayEntry current() { + List entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeDisplayEntry entry = current(); + return entry != null ? entry.display().result().resolveForFirstStack(SlotDisplayContext.fromLevel(minecraft.level)) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static Identifier pageTexture(String name, int page) { + return Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeDisplayEntry entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedItemContents contents = new StackedItemContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return entry.canCraft(contents); + } + + private void create() { + RecipeDisplayEntry entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(RenderPipelines.GUI_TEXTURED, pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, 0.0F, 0.0F, + PAGE_WIDTH / 2, PAGE_HEIGHT / 2, PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeDisplayEntry entry = current(); + if (entry == null) { + graphics.text(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.text(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.text(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.display(), mouseX, mouseY); + graphics.item(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphicsExtractor graphics, RecipeDisplay display, int mouseX, int mouseY) { + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + List slots; + int width; + switch (display) { + case ShapedCraftingRecipeDisplay shaped -> { + slots = shaped.ingredients(); + width = shaped.width(); + } + case ShapelessCraftingRecipeDisplay shapeless -> { + slots = shapeless.ingredients(); + width = 3; + } + default -> { + return; + } + } + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + List options = slots.get(i).resolveForStacks(context); + if (options.isEmpty()) { + continue; + } + ItemStack stack = options.get(tick % options.size()); + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.item(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.setTooltipForNextFrame(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphicsExtractor graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.text(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 00000000..f5ad2f9a --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,136 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamManager.COLORS.indexOf(ChatFormatting.RED); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamManager.COLORS.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + ChatFormatting teamColor = TeamManager.COLORS.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getName()).withStyle(teamColor))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.text(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphicsExtractor graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.text(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.text(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 00000000..0a9c3da6 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 176, 166); + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.text(font, label, 8, 6, color, false); + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.text(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java new file mode 100644 index 00000000..a320b923 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGui}: fuel and parachute slots on the left, the weapon + * panel on the right with its reload cost and button, and a button into the cargo chest for + * vehicles that have one. The panel is drawn from the chest texture so it looks like vanilla. + */ +public class VehicleScreen extends AbstractContainerScreen { + private static final Identifier CHEST = Identifier.withDefaultNamespace("textures/gui/container/generic_54.png"); + private static final Identifier SLOT = Identifier.withDefaultNamespace("container/slot"); + /** Height of the part above the player inventory. */ + private static final int TOP = 88; + private static final int PANEL_X = 72; + private static final int PANEL_WIDTH = 96; + private static final int LABEL = 0xFF404040; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF5555; + private static final int GREEN = 0xFF55FF55; + + private int weapon; + private boolean cargoButton; + private @Nullable Button reload; + private @Nullable Button previous; + private @Nullable Button next; + + public VehicleScreen(VehicleMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 176, TOP + 96); + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + previous = addRenderableWidget(Button.builder(Component.literal("<"), b -> step(-1)) + .bounds(leftPos + PANEL_X, topPos + 68, 16, 16).build()); + reload = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.reload"), b -> press(weapon)) + .bounds(leftPos + PANEL_X + 18, topPos + 68, PANEL_WIDTH - 36, 16).build()); + next = addRenderableWidget(Button.builder(Component.literal(">"), b -> step(1)) + .bounds(leftPos + PANEL_X + PANEL_WIDTH - 16, topPos + 68, 16, 16).build()); + AircraftEntity vehicle = menu.vehicle(); + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + cargoButton = def != null && def.cargoRows() > 0; + if (cargoButton) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.cargo"), b -> press(VehicleMenu.BUTTON_CARGO)) + .bounds(leftPos + imageWidth - 52, topPos + 3, 46, 13).build()); + } + updateButtons(); + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + private int weaponCount() { + AircraftEntity vehicle = menu.vehicle(); + VehicleWeapons weapons = vehicle != null ? vehicle.weapons() : null; + return weapons != null ? Math.min(weapons.sets().size(), VehicleMenu.MAX_WEAPONS) : 0; + } + + private void step(int direction) { + int count = weaponCount(); + if (count > 0) { + weapon = Math.floorMod(weapon + direction, count); + updateButtons(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + private void updateButtons() { + if (reload == null || previous == null || next == null) { + return; + } + int count = weaponCount(); + reload.visible = count > 0; + previous.visible = count > 1; + next.visible = count > 1; + reload.active = menu.canReload(weapon); + AircraftEntity vehicle = menu.vehicle(); + boolean grounded = vehicle != null && vehicle.canSupply(); + reload.setTooltip(grounded ? null : Tooltip.create(Component.translatable("gui.mcheli.vehicle.reload_hint"))); + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + int x = leftPos; + int y = topPos; + // The chest's title bar, its plain middle stretched down to the inventory, then the inventory. + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y, 0.0F, 0.0F, imageWidth, 17, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y + 17, 0.0F, 12.0F, imageWidth, TOP - 17, imageWidth, 1, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y + TOP, 0.0F, 126.0F, imageWidth, 96, 256, 256); + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x + VehicleMenu.FUEL_X + i * 18 - 1, y + VehicleMenu.FUEL_Y - 1, 18, 18); + } + for (int i = 0; i < menu.parachuteSlots(); i++) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x + VehicleMenu.PARACHUTE_X + i * 18 - 1, y + VehicleMenu.PARACHUTE_Y - 1, 18, 18); + } + graphics.fill(x + PANEL_X - 2, y + 17, x + PANEL_X + PANEL_WIDTH + 2, y + 66, 0xFF373737); + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + AircraftEntity vehicle = menu.vehicle(); + // Long names only give way to the Cargo button, where there is one. + int titleWidth = cargoButton ? imageWidth - 64 : imageWidth - 16; + graphics.text(font, font.plainSubstrByWidth(title.getString(), titleWidth), titleLabelX, titleLabelY, LABEL, false); + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, LABEL, false); + if (vehicle == null) { + return; + } + if (vehicle.maxFuel() > 0) { + // A percentage and a gauge: the exact units would run into the weapon panel. + int percent = Math.round(vehicle.fuelRatio() * 100.0F); + graphics.text(font, Component.translatable("gui.mcheli.vehicle.fuel", percent), VehicleMenu.FUEL_X, VehicleMenu.FUEL_Y - 11, LABEL, false); + int gaugeY = VehicleMenu.FUEL_Y + 18; + int width = VehicleInventory.FUEL_SLOTS * 18 - 2; + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width, gaugeY + 3, 0xFF373737); + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width * percent / 100, gaugeY + 3, percent <= 10 ? RED : GREEN); + } + if (menu.parachuteSlots() > 0) { + graphics.text(font, Component.translatable("gui.mcheli.vehicle.parachute"), VehicleMenu.PARACHUTE_X, VehicleMenu.PARACHUTE_Y - 11, LABEL, false); + } + weaponPanel(graphics, vehicle); + } + + /** Name, rounds carried against the maximum, and what one reload costs. */ + private void weaponPanel(GuiGraphicsExtractor graphics, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null && weaponCount() > 0 ? weapons.set(Math.min(weapon, weaponCount() - 1)) : null; + if (set == null) { + graphics.text(font, Component.translatable("gui.mcheli.vehicle.no_weapons"), PANEL_X, 20, WHITE, false); + return; + } + graphics.text(font, font.plainSubstrByWidth(set.displayName(), PANEL_WIDTH), PANEL_X, 20, WHITE, false); + int rounds = menu.rounds(weapon); + int max = set.maxAmmo(); + int color = rounds == 0 && max > 0 ? RED : rounds >= max ? GREEN : WHITE; + graphics.text(font, rounds + " / " + max, PANEL_X, 31, color, false); + int itemX = PANEL_X; + for (RoundItem cost : set.definition().roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null) { + continue; + } + graphics.item(new ItemStack(item), itemX, 42); + graphics.text(font, String.valueOf(cost.count()), itemX + 17, 47, WHITE, false); + itemX += 32; + if (itemX > PANEL_X + PANEL_WIDTH - 16) { + break; + } + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java new file mode 100644 index 00000000..b32040f5 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. + * + *

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. + */ +final class EngineSound extends RuntimeSoundInstance { + private static final float MAX_DOPPLER = 0.2F; + + private final AircraftEntity vehicle; + private final EngineSoundSource source; + private final boolean rotor; + private float doppler; + private double lastDistance = -1.0; + + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.vehicle = vehicle; + this.source = source; + this.rotor = rotor; + this.looping = true; + this.delay = 0; + update(); + } + + @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 = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); + boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); + if (aboard) { + x = vehicle.getX(); + 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.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java new file mode 100644 index 00000000..62e52284 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -0,0 +1,75 @@ +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 and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. + */ +public final class EngineSoundPlayer { + /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ + private static final int RESTART_DELAY = 20; + + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); + + private EngineSoundPlayer() { + } + + 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; + } + AircraftDefinition def = vehicle.definition(); + if (def == null) { + continue; + } + double range = def.soundRange * 1.2; + if (vehicle.distanceToSqr(player) > range * range) { + continue; + } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); + } + } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 00000000..e42d8cbd --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +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.Identifier; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.util.Util; +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 = super.resolve(manager); + if (events != null) { + return events; + } + file = RuntimeAssets.soundFile(identifier.getPath()); + if (file == null) { + return null; + } + // Streamed, so the file is read as it plays and never cached as a buffer. + sound = new Sound(identifier, new ConstantFloat(1.0F), new ConstantFloat(1.0F), 1, Sound.Type.FILE, true, false, 16); + return new WeighedSoundEvents(identifier, null); + } + + /** + * NeoForge calls this through its sound instance extension, which it overrides without saying + * so. Fabric has no such method and calls {@link #openStream} from a mixin. Forge declares it + * too but never calls it on this version, see {@code PackSoundBuffers} in its module. + */ + 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(path, looping); + } + return streamFile(source, looping); + } + + /** Streams an OGG file from disk, read as it plays and never cached as a buffer. */ + public static CompletableFuture streamFile(Path source, boolean 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-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..26401727 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; + +/** + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, so the undo history and the view are + * as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + + public static void tick() { + tickSubmit(); + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.screen == null) { + mc.setScreen(screen); + } + } + } + + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + + public static void loggedOut() { + submitting = null; + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..7fa4ba6c --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,201 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.home.packs"), b -> minecraft.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().name() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 10, TEXT); + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.text(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.text(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..1b3213b9 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Util.getPlatform().openPath(folder); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.setScreen(new WorkshopHomeScreen()); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 10, TEXT); + if (rows.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphicsExtractor graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphicsExtractor graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.text(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..6017c3cd --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1646 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.AssetImport; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Matrix3x2fStack; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

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

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

Movement is simulated wherever {@link #isLocalInstanceAuthoritative()} 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 VehicleEntity { + 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); + private static final EntityDataAccessor DATA_FUEL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + /** The UAV station a drone is flown from, by entity id, or 0. */ + private static final EntityDataAccessor DATA_STATION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_REPELLING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SEARCH_LIGHT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + /** Marks a player flying a drone from a station, so a stale hide can be undone at login. */ + public static final String REMOTE_PILOT_TAG = "mcheli_remote_pilot"; + + // 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; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + 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<>(); + /** The station a drone is flown from, and where its pilot goes back to. Server only. */ + private @Nullable UUID station; + private @Nullable Vec3 stationReturn; + /** Remote pilots leaving this tick, by entity id, with where they come back down. */ + private final Map remoteReturns = new HashMap<>(); + /** Where a workshop test flight started, or {@code null} for an ordinary vehicle. Server only. */ + private @Nullable Vec3 testFlightStart; + private boolean testFlightOver; + /** How far the ropes hang below their hooks, as a negative number; both sides let them out. */ + private float ropeLength; + private int repellingTicks; + private int lastRope; + /** Passengers are leaving one by one under parachutes. Server only. */ + private boolean droppingTroops; + + 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; + /** The fuel load from which an aircraft counts as heavy: the H8K flying boat and up. */ + private static final int HEAVY_FUEL = 3000; + /** How long a wreck stays, burning, before it disappears: 30 seconds. */ + private static final int WRECK_TICKS = 600; + /** 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; + /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ + private int wreckTicks; + /** Ticks since this aircraft was shot down, for the crash spin. Counted on both sides. */ + private int spinTicks; + 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; + + /** Fuel burnt but not yet taken from the tank, which only holds whole units. Server only. */ + private double fuelBurnt; + /** Ticks a fuel truck nearby keeps this vehicle from burning fuel. Server only. */ + private int fuelSuppliedCount; + /** One bit per seat whose occupant has night vision on. Server only, not saved. */ + private int nightVisionSeats; + /** Fuel and parachute slots and the cargo chest. Only the server's copy holds anything. */ + private final VehicleInventory inventory = new VehicleInventory(this); + + // 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; + /** + * How far a hull reaches past its main box, at most, when looking for neighbours: other + * vehicles are found by their main box, and the longest hulls stretch about this far from it. + */ + private static final double HULL_REACH = 24.0; + /** 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) { + super.defineSynchedData(builder); + 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); + builder.define(DATA_FUEL, 0); + builder.define(DATA_STATION, 0); + builder.define(DATA_REPELLING, false); + builder.define(DATA_SEARCH_LIGHT, 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); + entityData.set(DATA_FUEL, definition.maxFuel); + 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); + } + + /** + * {@code repair}: adds hit points up to the maximum. A wreck stays a wreck. Server only. + * + * @return whether anything was repaired + */ + public boolean repair(int amount) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || hp() >= def.maxHp) { + return false; + } + entityData.set(DATA_HP, Math.min(hp() + amount, def.maxHp)); + return true; + } + + /** + * {@code switchNextTextureName}: the next paint scheme the definition lists. Server only. + * + * @return false when the vehicle has only one + */ + public boolean nextTexture() { + AircraftDefinition def = definition(); + if (def == null || def.textures().size() < 2) { + return false; + } + List textures = def.textures(); + int next = (textures.indexOf(texture()) + 1) % textures.size(); + entityData.set(DATA_TEXTURE, textures.get(next)); + return true; + } + + // Fuel + + public int fuel() { + return entityData.get(DATA_FUEL); + } + + public int maxFuel() { + AircraftDefinition def = definition(); + return def != null ? def.maxFuel : 0; + } + + /** {@code getFuelP}: how full the tank is, 0 to 1. Vehicles without a tank read as full. */ + public float fuelRatio() { + int max = maxFuel(); + return max <= 0 ? 1.0F : (float) fuel() / max; + } + + /** {@code canUseFuel}: a vehicle without a tank never runs dry. */ + public boolean hasFuel() { + return maxFuel() <= 0 || fuel() > 1; + } + + /** Sets the tank outright; a drone leaves its station with 5%. Server only. */ + public void setFuelLevel(int fuel) { + entityData.set(DATA_FUEL, Math.clamp(fuel, 0, Math.max(maxFuel(), 0))); + } + + /** + * Pours up to {@code amount} into the tank. Server only. + * + * @return how much went in + */ + public int refuel(int amount) { + int added = Math.clamp(maxFuel() - fuel(), 0, Math.max(amount, 0)); + if (added > 0) { + entityData.set(DATA_FUEL, fuel() + added); + } + return added; + } + + // Night vision + + /** A crew member switched night vision on or off. Server only. */ + public void setNightVision(Player player, boolean on) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (seat < 0 || seat >= 31) { + return; + } + if (on && def != null && def.nightVision && !isDestroyed()) { + nightVisionSeats |= 1 << seat; + } else if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(player); + } + } + + /** Keeps night vision on for everyone in the crew who switched it on, see {@link NightVision}. */ + private void tickNightVision() { + if (nightVisionSeats == 0) { + return; + } + if (isDestroyed()) { + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (crew != null) { + NightVision.end(crew); + } + } + nightVisionSeats = 0; + return; + } + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (hasNightVision(seat) && crew != null) { + NightVision.topUp(crew); + } + } + } + + /** Only the first 31 seats can switch it on; racks and the like come after them anyway. */ + private boolean hasNightVision(int seat) { + return seat >= 0 && seat < 31 && (nightVisionSeats & 1 << seat) != 0; + } + + /** + * {@code updateFuel}: once a second the engine burns its {@code FuelConsumption}, scaled by the + * throttle and topping out at 70% throttle. Nothing burns next to a fuel truck or with the + * server's infinite fuel on. + */ + private void burnFuel(AircraftDefinition def) { + if (def.maxFuel <= 0 || isDestroyed()) { + return; + } + if (fuelSuppliedCount > 0) { + fuelSuppliedCount--; + return; + } + if (ticksAlive % 20 != 0 || fuel() <= 1 || throttle <= 0.0 || ServerConfig.infiniteFuel()) { + return; + } + fuelBurnt += Math.min(throttle * 1.4, 1.0) * def.fuelConsumption; + if (fuelBurnt > 1.0) { + int whole = (int) fuelBurnt; + fuelBurnt -= whole; + entityData.set(DATA_FUEL, Math.max(fuel() - whole, 0)); + } + } + + /** + * {@code supplyFuel}: a fuel truck ({@code FuelSupplyRange}) tops up every vehicle within range, + * 30 units every half second. While it is on the ground it only reaches vehicles that are on + * the ground too. + */ + private void supplyFuel(AircraftDefinition def) { + float range = def.fuelSupplyRange; + if (range <= 0.0F || ticksAlive % 10 != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + if (other == this) { + continue; + } + if (!onGround() || other.onGround()) { + other.refuel(30); + } + other.fuelSuppliedCount = 40; + } + } + + // Inventory and supply + + public VehicleInventory inventory() { + return inventory; + } + + /** + * {@code canSupply}: fuel cans and resupply only work while the vehicle stands on something, + * within three blocks; a vehicle that cannot float must not be in the water either. + */ + public boolean canSupply() { + AircraftDefinition def = definition(); + return def != null && hasBlockBelow(1, 3, false) && (def.floats || !isInWater()); + } + + /** {@code openGui}: shows a crew member the vehicle screen. Server only. */ + public void openScreen(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || player.getVehicle() != this) { + return; + } + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new VehicleMenu(id, playerInventory, this), displayName(def))); + } + + /** + * {@code openInventory}: shows the cargo chest of a vehicle with an {@code InventorySize}. + * Server only. + * + * @return false when the vehicle has none + */ + public boolean openCargo(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || def.cargoRows() <= 0 || isDestroyed() || !inventory.stillValid(player)) { + return false; + } + int rows = Math.min(def.cargoRows(), 6); + MenuType type = switch (rows) { + case 1 -> MenuType.GENERIC_9x1; + case 2 -> MenuType.GENERIC_9x2; + case 3 -> MenuType.GENERIC_9x3; + case 4 -> MenuType.GENERIC_9x4; + case 5 -> MenuType.GENERIC_9x5; + default -> MenuType.GENERIC_9x6; + }; + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new ChestMenu(type, id, playerInventory, inventory.cargo(), rows), + displayName(def))); + return true; + } + + /** The vehicle's name as its item shows it. */ + private static Component displayName(AircraftDefinition def) { + return Component.translatableWithFallback("item.mcheli." + def.name(), def.displayName); + } + + /** + * {@code supplyAmmoToOtherAircraft}: an ammunition truck ({@code AmmoSupplyRange}) tops up the + * weapons of every vehicle within range that stands on something, a tenth of each weapon's + * maximum every two seconds. + */ + private void supplyAmmo(AircraftDefinition def) { + float range = def.ammoSupplyRange; + if (range <= 0.0F || ticksAlive % SupplyRules.AMMO_TRUCK_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + VehicleWeapons theirs = other.weapons(); + if (other == this || theirs == null || other.isDestroyed() || !other.canSupply()) { + continue; + } + for (WeaponSet set : theirs.sets()) { + int rounds = SupplyRules.roundsFromTruck(set.ammo() + set.restAmmo(), set.maxAmmo()); + if (rounds > 0) { + set.setRestAmmo(set.restAmmo() + rounds); + if (set.ammo() <= 0) { + set.reloadMagazine(); + } + } + } + } + } + + /** {@code repairOtherAircraft}: a repair vehicle ({@code RepairOtherVehicles}) mends those in range. */ + private void repairOthers(AircraftDefinition def) { + float range = def.repairOtherVehiclesRange; + if (range <= 0.0F || ticksAlive % SupplyRules.REPAIR_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + AircraftDefinition theirs = other.definition(); + if (other != this && theirs != null) { + other.repair(SupplyRules.repair(other.hp(), theirs.maxHp, def.repairOtherVehiclesValue)); + } + } + } + + /** 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 isLocalInstanceAuthoritative() && !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 + + /** + * Makes this a workshop test flight: it takes no damage, is not saved with the world, and goes + * away once its pilot gets out, putting them back where they started. + */ + public void startTestFlight(Vec3 start) { + testFlightStart = start; + } + + @Override + public boolean shouldBeSaved() { + return testFlightStart == null && super.shouldBeSaved(); + } + + @Override + public void tick() { + if (testFlightOver) { + discard(); + return; + } + rollO = roll(); + if (getHurtTime() > 0) { + setHurtTime(getHurtTime() - 1); + } + super.tick(); + interpolation.interpolate(); + ticksAlive++; + lastSeats.clear(); + remoteReturns.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 (!level().isClientSide()) { + supplyFuel(def); + supplyAmmo(def); + repairOthers(def); + burnFuel(def); + if (def.maxFuel > 0 && fuel() < def.maxFuel && !isDestroyed()) { + inventory.pourFuelCans(ticksAlive); + } + tickNightVision(); + tickStation(); + } + tickRopes(def); + + if (isSimulatingHere()) { + if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { + input.clear(); + } + if (isRepelling()) { + // {@code MCH_AircraftClientTickHandler}: the pilot holds a hover while the ropes are out. + input.throttleUp = false; + input.throttleDown = false; + input.left = false; + input.right = false; + } + if (!hasFuel()) { + // An empty tank: the throttle runs down by itself, as with the pilot's hand off it. + input.throttleUp = false; + } + 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 (!isLocalInstanceAuthoritative()) { + 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); + VehicleParticles.tick(this, 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) { + hurtServer((ServerLevel) level(), 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)) { + hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), crashDamage(previous.horizontalDistance())); + } + if (speed > VEHICLE_CRASH_SPEED && collisionCooldown == 0) { + // A blocked move can leave the hulls up to a tick of travel apart; when the vehicle + // stopped short this tick, look that far for what stopped it. + boolean stopped = velocity.length() < previous.length() * 0.5; + double reach = stopped ? 0.5 + previous.length() : 0.5; + for (AircraftEntity other : solidNeighbours(hullBounds().inflate(reach))) { + if (!hullsTouch(other, reach)) { + continue; + } + 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); + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), damage); + other.hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), 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) { + hurtServer((ServerLevel) level(), 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() || current == 0 && station != null) { + // A remote pilot stays at the controls; they are not really aboard. + 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); + } + + // UAV stations + + /** Whether a player at a station could take this drone's controls now. */ + public boolean canTakeRemotePilot() { + AircraftDefinition def = definition(); + return def != null && def.uav && !def.targetDrone && !isDestroyed() && isSeatFree(0); + } + + /** + * Seats {@code player}, who sits at {@code from}, at this drone's controls. The pilot is hidden + * and takes no harm while flying, since in the original they never left the station. Server only. + */ + public boolean takeRemotePilot(ServerPlayer player, UavStationEntity from) { + if (!canTakeRemotePilot()) { + return false; + } + pendingSeat = 0; + boolean seated = player.startRiding(this, true, true); + pendingSeat = EMPTY; + if (!seated) { + return false; + } + station = from.getUUID(); + stationReturn = from.returnPosition(); + entityData.set(DATA_STATION, from.getId()); + player.addTag(REMOTE_PILOT_TAG); + player.setInvisible(true); + player.setInvulnerable(true); + return true; + } + + /** Sends the remote pilot back to the station, if one is flying. Server only. */ + public void releaseRemotePilot() { + Entity pilot = occupant(0); + if (station != null && pilot != null) { + pilot.stopRiding(); + } + } + + /** The station this drone is flown from, as the client sees it. */ + public @Nullable UavStationEntity stationEntity() { + return level().getEntity(entityData.get(DATA_STATION)) instanceof UavStationEntity s ? s : null; + } + + /** Flat distance to the station this drone is flown from, or -1 without one. */ + public double stationDistance() { + UavStationEntity from = stationEntity(); + if (from == null) { + return -1.0; + } + double dx = getX() - from.getX(); + double dz = getZ() - from.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } + + /** Whether {@code entity} flies this drone from a station rather than sitting in it. */ + public boolean isRemotePilot(Entity entity) { + return (station != null || entityData.get(DATA_STATION) != 0) && occupant(0) == entity; + } + + private void endRemoteControl(Entity pilot) { + UavStationEntity from = level() instanceof ServerLevel server && station != null + && server.getEntity(station) instanceof UavStationEntity s ? s : null; + Vec3 home = from != null ? from.returnPosition() : stationReturn; + if (home != null) { + remoteReturns.put(pilot.getId(), home); + } + clearRemotePilot(pilot); + if (from != null) { + from.setLinked(false); + } + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + } + + /** Undoes what {@link #takeRemotePilot} did to the player. */ + public static void clearRemotePilot(Entity pilot) { + if (pilot.removeTag(REMOTE_PILOT_TAG)) { + pilot.setInvisible(false); + pilot.setInvulnerable(false); + } + } + + /** + * {@code updateUAV}: the link breaks when the station is gone, and flying out of its range + * loses the drone, as in the original. + */ + private void tickStation() { + if (station == null || !(level() instanceof ServerLevel server)) { + return; + } + Entity pilot = occupant(0); + if (pilot == null) { + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + return; + } + UavStationEntity from = server.getEntity(station) instanceof UavStationEntity s && s.isAlive() ? s : null; + if (from == null) { + pilot.stopRiding(); + return; + } + entityData.set(DATA_STATION, from.getId()); + if (!from.inRange(this) && !isDestroyed()) { + if (pilot instanceof Player player) { + player.sendOverlayMessage(Component.translatable("message.mcheli.uav.lost")); + } + pilot.stopRiding(); + hurtServer(server, damageSources().fellOutOfWorld(), hp() + 10.0F); + } + } + + @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); + } + // Coming back aboard after a reload, a remote pilot included. + Integer saved = savedSeats.get(passenger.getUUID()); + if (saved != null && isSeatFree(saved)) { + return true; + } + // Cargo only boards through a rack, never into a crew seat. + return !(passenger instanceof AircraftEntity || passenger instanceof ContainerEntity) && 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) { + if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(passenger); + } + lastSeats.put(passenger.getId(), seat); + seatIds[seat] = EMPTY; + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onLeft(seat); + } + } + if (seat == 0) { + input.clear(); + if (station != null) { + endRemoteControl(passenger); + } + if (testFlightStart != null) { + remoteReturns.put(passenger.getId(), testFlightStart); + testFlightOver = true; + } + } + } + + @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); + } else if (def != null && passenger instanceof ContainerEntity container) { + RackDefinition rack = def.rack(seatOf(passenger)); + container.setYRot(getYRot() + (rack != null ? rack.yaw() : 0.0F)); + container.setDeltaMovement(Vec3.ZERO); + } + } + + @Override + public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + Vec3 home = remoteReturns.remove(passenger.getId()); + if (home != null) { + return home; + } + 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); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + ItemStack held = player.getItemInHand(hand); + if (held.getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (definition() != null && (held.getItem() instanceof FuelItem || held.getItem() instanceof WrenchItem)) { + if (level().isClientSide()) { + return InteractionResult.SUCCESS; + } + return held.getItem() instanceof FuelItem ? FuelItem.pour(held, this, player) : WrenchItem.useOn(held, this, player, hand); + } + InteractionResult result = super.interact(player, hand, location); + AircraftDefinition def = definition(); + if (result == InteractionResult.PASS && player.isSecondaryUseActive() && def != null && def.cargoRows() > 0 + && !isDestroyed() && player.getVehicle() != this) { + // Sneaking opens the cargo chest from outside, as in the original. + if (player instanceof ServerPlayer server) { + openCargo(server); + } + return InteractionResult.SUCCESS; + } + if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { + return result; + } + 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(); + boardNearbyMobs(); + } + } + return InteractionResult.SUCCESS; + } + + // Racks + + /** Loads {@code cargo} into the given rack seat. Server only. */ + private boolean loadCargo(Entity cargo, int seat) { + if (!isSeatFree(seat) || cargo.isPassenger()) { + return false; + } + pendingSeat = seat; + boolean loaded = cargo.startRiding(this, true, 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; + } + + /** Whether rack {@code seat} carries containers, {@code AddRack} naming {@code container}. */ + public boolean acceptsContainer(ContainerEntity container, int seat) { + AircraftDefinition def = definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + return rack != null && !isPassenger() && !container.isPassenger() + && rack.accepts(ContainerEntity.CARGO_NAME, ContainerEntity.CARGO_NAME); + } + + 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 or container 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()); + boolean loaded = false; + for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { + if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + loaded = true; + break; + } + } + if (!loaded) { + for (ContainerEntity container : level().getEntitiesOfClass(ContainerEntity.class, area)) { + if (acceptsContainer(container, seat) && loadCargo(container, 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.snapTo(at.x, at.y, at.z, getYRot() + rack.yaw(), 0.0F); + // As in the original, with no ground in the 20 blocks below it goes down under a canopy. + if (!(cargo instanceof AircraftEntity) && !hasBlockBelow(3, 20, false)) { + ParachuteEntity.open(level(), cargo, ParachuteEntity.Kind.DROP); + } + 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(); + snapTo(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); + } + + /** + * {@code ejectSeat}: throws the occupant clear of the aircraft in an ejection seat, whose + * parachute opens once it starts to fall. Each ejection takes a parachute from the parachute + * slots of the vehicle screen; without one the seat stays put. Creative players need none. + * Server only. + */ + public void eject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + if (def == null || !canEject(entity)) { + return; + } + boolean creative = entity instanceof Player player && player.getAbilities().instabuild; + if (!creative && !inventory.takeParachute()) { + if (entity instanceof ServerPlayer player) { + ModSounds.playTo(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.no_parachute")); + } + 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.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.EJECTION_SEAT); + if (canopy != null && !canopy.status()) { + canopy.set(true, false); + modeSwitchCooldown = 40; + } + ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); + } + + // Search lights + + public boolean isSearchLightOn() { + AircraftDefinition def = definition(); + return def != null && !def.searchLights.isEmpty() && entityData.get(DATA_SEARCH_LIGHT); + } + + /** {@code canSwitchSearchLight}: the pilot or the first crew seat, in vehicles that have one. */ + public boolean canSwitchSearchLight(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && !def.searchLights.isEmpty() && seat >= 0 && seat <= 1; + } + + /** Server only. */ + public void toggleSearchLight(Entity entity) { + if (canSwitchSearchLight(entity)) { + entityData.set(DATA_SEARCH_LIGHT, !entityData.get(DATA_SEARCH_LIGHT)); + } + } + + /** + * Where a turning search light points: the first crew seat's view, else the pilot's, as + * {@code renderLight} took them. Null with nobody aboard to aim it. + */ + public @Nullable Entity searchLightAimer() { + Entity crew = occupant(1); + return crew != null ? crew : occupant(0); + } + + // Troops: parachute drops and ropes + + public boolean isRepelling() { + return entityData.get(DATA_REPELLING); + } + + /** How far the ropes hang below their hooks, as a negative number. */ + public float ropeLength() { + return ropeLength; + } + + /** Where rope {@code index} hangs from, in the world. */ + public Vec3 ropePosition(int index) { + AircraftDefinition def = definition(); + return def != null && index >= 0 && index < def.repellingHooks.size() ? toWorld(def.repellingHooks.get(index).position()) : position(); + } + + /** + * {@code onUpdate_Repelling} and {@code onUpdate_UnmountCrew}: the ropes run out towards the + * ground, then the mobs aboard slide down them one per hook interval; a parachute drop lets one + * passenger out per {@code MobDropOption} interval while the hatch is open. + */ + private void tickRopes(AircraftDefinition def) { + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + int clear = airBelow(50); + if (ropeLength > -50.0F && ropeLength > -clear) { + ropeLength -= level().isClientSide() ? 0.3F : 0.25F; + } + } else { + ropeLength = 0.0F; + } + } + if (level().isClientSide()) { + return; + } + if (isRepelling() && isDestroyed()) { + entityData.set(DATA_REPELLING, false); + } + if (!isRepelling()) { + repellingTicks = 0; + } else if (repellingTicks < 60) { + repellingTicks++; + } else { + for (int rope = 0; rope < def.repellingHooks.size(); rope++) { + if (ticksAlive % def.repellingHooks.get(rope).interval() != 0) { + continue; + } + for (int seat = 1; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + rappel(mob, rope); + break; + } + } + } + } + if (droppingTroops) { + if (hasBlockBelow(3, 10, false) || isDestroyed()) { + droppingTroops = false; + } else if ((hatch == null || hatch.isOn()) && ticksAlive % def.dropInterval == 0 && !dropNextTroop(def)) { + droppingTroops = false; + } + } + } + + /** Air blocks straight below, up to {@code max}. */ + private int airBelow(int max) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + int i = 0; + while (i < max && pos.getY() - i > level().getMinY() && level().getBlockState(pos.setY(blockPosition().getY() - i - 1)).isAir()) { + i++; + } + return i; + } + + /** + * {@code canStartRepelling}: a helicopter in hover mode, level and holding still. The original + * compared against where it was a few ticks before. + */ + private boolean canStartRepelling() { + return this instanceof HelicopterEntity && isHoveringMode() && Math.abs(getXRot()) < 3.0F && Math.abs(roll()) < 3.0F + && lastTickPos != null && position().distanceTo(lastTickPos) < 0.05; + } + + /** + * The pilot's drop key, {@code unmountCrew}: with ropes, lets them down from a steady hover + * or pulls them up again; with {@code EnableParachuting}, starts or stops dropping the mobs + * aboard under parachutes; otherwise, or close to the ground, simply lets the mobs out. + * Server only. + * + * @return false if nothing could be done, so the pilot hears it + */ + public boolean dropCrew() { + AircraftDefinition def = definition(); + if (def == null || isDestroyed()) { + return false; + } + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + entityData.set(DATA_REPELLING, false); + return true; + } + if (hasBlockBelow(3, 4, false)) { + return unloadMobs(); + } + if (!canStartRepelling()) { + return false; + } + entityData.set(DATA_REPELLING, true); + repellingTicks = 0; + return true; + } + if (droppingTroops) { + droppingTroops = false; + return true; + } + if (def.parachuting && !hasBlockBelow(3, 10, false)) { + droppingTroops = true; + if (hatch != null && hatch.isOff()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } + return true; + } + return unloadMobs(); + } + + /** Lets every mob in a crew seat out, as in {@code unmountCrew(false)}. */ + private boolean unloadMobs() { + AircraftDefinition def = definition(); + boolean any = false; + for (int seat = 1; def != null && seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + mob.stopRiding(); + any = true; + } + } + return any; + } + + /** The next mob behind the second seat leaves at the drop point under a parachute. */ + private boolean dropNextTroop(AircraftDefinition def) { + for (int seat = 2; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + parachuteOut(mob, def); + return true; + } + } + return false; + } + + private void parachuteOut(Entity entity, AircraftDefinition def) { + Vec3 out = toWorld(def.dropPosition); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement()); + entity.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.DROP); + } + + /** + * A passenger's own drop key, {@code MCH_ClientSeatTickHandler}: slide down a rope once the + * ropes are out, or jump with a parachute from behind the second seat of a vehicle with + * {@code EnableParachuting} and room below. Server only. + * + * @return false if neither is possible right now + */ + public boolean jumpOut(Player player) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (def == null || seat <= 0 || seat >= def.seatCount() || isDestroyed()) { + return false; + } + if (isRepelling() && repellingTicks > 50 && !def.repellingHooks.isEmpty()) { + lastRope = (lastRope + 1) % def.repellingHooks.size(); + rappel(player, lastRope); + return true; + } + if (def.parachuting && seat >= 2 && !hasBlockBelow(3, 13, false)) { + parachuteOut(player, def); + return true; + } + return false; + } + + /** {@code unmountEntityRepelling}: two blocks under the hook, onto a rope rider that lets them down. */ + private void rappel(Entity entity, int rope) { + Vec3 at = ropePosition(rope).add(0.0, -2.0, 0.0); + entity.stopRiding(); + entity.teleportTo(at.x, at.y, at.z); + entity.setDeltaMovement(Vec3.ZERO); + entity.resetFallDistance(); + RappelEntity rider = new RappelEntity(ModEntities.RAPPEL.get(), level()); + rider.attach(this, rope); + rider.setPos(at); + level().addFreshEntity(rider); + entity.startRiding(rider, true, true); + lastRope = rope; + } + + /** + * {@code mountMobToSeats}: when the pilot climbs in, mobs standing close by take the free crew + * seats, so villagers and the like can be flown somewhere. Server only. + */ + private void boardNearbyMobs() { + AircraftDefinition def = definition(); + if (def == null || def.uav) { + return; + } + for (Mob mob : level().getEntitiesOfClass(Mob.class, getBoundingBox().inflate(3.0, 2.0, 3.0), m -> !m.isPassenger() && !m.isVehicle())) { + for (int seat = 1; seat < def.seatCount(); seat++) { + if (isSeatFree(seat)) { + pendingSeat = seat; + mob.startRiding(this, true, true); + pendingSeat = EMPTY; + break; + } + } + } + } + + // 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(@Nullable Entity other) { + 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.hull()) { + Optional hit = clip(hullBox(box).inflate(inflate), from, to); + if (hit.isPresent() && from.distanceToSqr(hit.get()) < bestDistance) { + best = hit; + bestDistance = from.distanceToSqr(hit.get()); + hitBoxFactor = box.damageFactor(); + } + } + } + return best; + } + + /** + * One {@code BoundingBox} in world space. Like the original's, it stays axis aligned: the + * centre turns with the hull, the box itself does not. + */ + private AABB hullBox(BoundingBoxDefinition box) { + Vec3 c = toWorld(box.offset()); + double hw = box.width() / 2.0; + double hh = box.height() / 2.0; + return new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw); + } + + /** The whole hull: the main hit box, every {@code BoundingBox} and the generated boxes, in world space. */ + public List hullBoxes() { + AircraftDefinition def = definition(); + if (def == null || def.hull().isEmpty()) { + return List.of(getBoundingBox()); + } + List boxes = new ArrayList<>(def.hull().size() + 1); + boxes.add(getBoundingBox()); + for (BoundingBoxDefinition box : def.hull()) { + boxes.add(hullBox(box)); + } + return boxes; + } + + /** The smallest box around the whole hull. */ + private AABB hullBounds() { + AABB bounds = getBoundingBox(); + AircraftDefinition def = definition(); + if (def != null) { + for (BoundingBoxDefinition box : def.hull()) { + bounds = bounds.minmax(hullBox(box)); + } + } + return bounds; + } + + /** Whether any part of this hull touches any part of {@code other}'s, grown by {@code inflate}. */ + private boolean hullsTouch(AircraftEntity other, double inflate) { + // Hulls generated from big models run to a thousand boxes, so skip ours that are nowhere near. + AABB around = other.hullBounds().inflate(inflate); + List theirs = null; + for (AABB mine : hullBoxes()) { + AABB grown = mine.inflate(inflate); + if (!grown.intersects(around)) { + continue; + } + if (theirs == null) { + theirs = other.hullBoxes(); + } + for (AABB box : theirs) { + if (grown.intersects(box)) { + return true; + } + } + } + return false; + } + + /** + * Vehicles whose hulls block this one: everything nearby except its own carrier or cargo, and + * vehicles either of the two could load onto a rack, which have to drive into each other. + */ + private List solidNeighbours(AABB area) { + return level().getEntitiesOfClass(AircraftEntity.class, area.inflate(HULL_REACH), other -> other != this && !other.isRemoved() + && other.definition() != null && !other.isPassenger() && !isPassengerOfSameVehicle(other) + && other.getVehicle() != this && getVehicle() != other && !canCarry(other) && !other.canCarry(this)); + } + + /** Whether a free rack of this vehicle would take {@code cargo}. */ + private boolean canCarry(AircraftEntity cargo) { + AircraftDefinition def = definition(); + if (def == null) { + return false; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (acceptsCargo(cargo, seat)) { + return true; + } + } + return false; + } + + /** + * Not in the original, where only the main box at a vehicle's feet collided: the whole hull + * blocks other vehicles. A move that would push this hull into another vehicle's is undone, + * sideways first and then completely, so a helicopter can also set down on a parked truck. + * Hulls that already overlapped, say after two vehicles were placed into each other, may move + * apart. + */ + @Override + public void move(MoverType type, Vec3 delta) { + if (type != MoverType.SELF || isPassenger() || definition() == null) { + super.move(type, delta); + return; + } + List near = solidNeighbours(hullBounds().expandTowards(delta)); + if (near.isEmpty()) { + super.move(type, delta); + return; + } + List overlapping = new ArrayList<>(); + for (AircraftEntity other : near) { + if (hullsTouch(other, 0.0)) { + overlapping.add(other); + } + } + Vec3 before = position(); + super.move(type, delta); + if (!entersHull(near, overlapping)) { + return; + } + Vec3 m = getDeltaMovement(); + setPos(before.x, getY(), before.z); + setDeltaMovement(0.0, m.y, 0.0); + if (entersHull(near, overlapping)) { + setPos(before); + setDeltaMovement(Vec3.ZERO); + } + } + + private boolean entersHull(List near, List overlapping) { + for (AircraftEntity other : near) { + if (!overlapping.contains(other) && hullsTouch(other, 0.0)) { + return true; + } + } + return false; + } + + /** 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + float boxFactor = consumeHitBoxFactor(); + if (testFlightStart != null || isRemoved() || isInvulnerableToBase(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() || isHeavy() && isNearGround(3)) { + 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(level, 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; + 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; + } + if (isHeavy()) { + startCookOff(null); + return; + } + exploded = true; + AircraftDefinition def = definition(); + float size = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + Vec3 at = def != null && def.fuelTankPosition != null ? toWorld(def.fuelTankPosition) : position(); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, null, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + if (!isInWater()) { + Explosions.crashFireball(level, at.x, at.y, at.z, size); + } + } + + /** + * Airliners, bombers and transports ({@code MaxFuel} of {@value HEAVY_FUEL} or more): like a + * boat, they go up in three blasts rather than one fireball. + */ + private boolean isHeavy() { + AircraftDefinition def = definition(); + return def != null && def.maxFuel >= HEAVY_FUEL; + } + + /** 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, or a heavy aircraft wrecked on the ground, does not go up in + * one crash fireball; its fuel and magazine cook off in three blasts over about five seconds, + * then a hull in the water 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 { + // A boat or a flying boat goes under; a heavy aircraft on land stays where it burns. + if (isInWater() || waterDepth() > 0.0) { + 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; + crashSpin(); + 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(); + } + } + + /** + * Not in the original: a shot down plane rolls into a nose-down spiral and a helicopter spins + * with some wobble, until it is close to the ground. Tanks and cars just stop. + */ + private void crashSpin() { + if (!(this instanceof HelicopterEntity) && !(this instanceof PlaneEntity) || isNearGround(3) || isInWater()) { + return; + } + int direction = CrashSpin.direction(getId()); + int ticks = spinTicks++; + if (this instanceof HelicopterEntity) { + setYRot(getYRot() + CrashSpin.helicopterYawRate(ticks, direction)); + setXRot(Mth.approach(getXRot(), CrashSpin.helicopterWobblePitch(ticks, getId()), 2.0F)); + roll = Mth.approach(roll, CrashSpin.helicopterWobbleRoll(ticks, getId()), 3.0F); + } else { + setYRot(getYRot() + CrashSpin.planeYawRate(ticks, direction)); + setXRot(CrashSpin.planePitch(getXRot())); + roll = AircraftRotation.wrap(roll + CrashSpin.planeRollRate(ticks, direction)); + } + } + + /** {@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 = def.enginePosition != null ? toWorld(def.enginePosition) : 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); + } + } + + /** A vehicle that is destroyed, packed up or killed spills what it carried; one that unloads keeps it. */ + @Override + public void remove(RemovalReason reason) { + if (reason.shouldDestroy() && !level().isClientSide() && !isRemoved()) { + inventory.dropAll(); + } + super.remove(reason); + } + + @Override + protected Item getDropItem() { + AircraftDefinition def = definition(); + return def != null ? ModItems.forKind(def.kind()) : Items.AIR; + } + + @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()); + } + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putString("Type", typeName()); + output.putString("Texture", texture()); + output.putInt("Hp", hp()); + output.putInt("Fuel", fuel()); + output.putFloat("Roll", roll); + output.putBoolean("Hovering", isHoveringMode()); + output.putBoolean("GunnerMode", isGunnerMode()); + output.putInt("Parts", partBits()); + output.putInt("DespawnCount", despawnCount); + output.putInt("CookOff", cookOffBlasts); + output.putBoolean("Sinking", entityData.get(DATA_SINKING)); + output.putBoolean("SearchLight", entityData.get(DATA_SEARCH_LIGHT)); + if (weapons != null) { + output.putIntArray("WeaponsAmmo", weapons.saveAmmo()); + } + inventory.save(output); + if (station != null && stationReturn != null) { + output.store("Station", UUIDUtil.CODEC, station); + output.store("StationReturn", Vec3.CODEC, stationReturn); + } + ValueOutput.TypedOutputList seats = output.list("Seats", Codec.STRING); + for (int i = 0; i < seatIds.length; i++) { + Entity e = occupant(i); + seats.add(e != null ? e.getUUID().toString() : ""); + } + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + entityData.set(DATA_TYPE, input.getStringOr("Type", "")); + entityData.set(DATA_TEXTURE, input.getStringOr("Texture", "")); + entityData.set(DATA_PARTS, input.getIntOr("Parts", 0)); + AircraftDefinition def = definition(); + entityData.set(DATA_HP, input.getIntOr("Hp", def != null ? def.maxHp : 1)); + // 1.0.0 and the earlier betas had no fuel; their vehicles come back with a full tank. + entityData.set(DATA_FUEL, input.getIntOr("Fuel", def != null ? def.maxFuel : 0)); + roll = input.getFloatOr("Roll", 0.0F); + setHovering(input.getBooleanOr("Hovering", false)); + entityData.set(DATA_GUNNER_MODE, input.getBooleanOr("GunnerMode", false)); + despawnCount = input.getIntOr("DespawnCount", 0); + if (hp() <= 0 && despawnCount <= 0) { + despawnCount = WRECK_TICKS; + } + cookOffBlasts = input.getIntOr("CookOff", 0); + cookOffDelay = cookOffBlasts > 0 ? 20 : 0; + entityData.set(DATA_SINKING, input.getBooleanOr("Sinking", false)); + entityData.set(DATA_SEARCH_LIGHT, input.getBooleanOr("SearchLight", false)); + exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); + input.getIntArray("WeaponsAmmo").ifPresent(ammo -> { + if (weapons != null) { + weapons.loadAmmo(ammo); + } else { + savedAmmo = ammo; + } + }); + inventory.load(input); + station = input.read("Station", UUIDUtil.CODEC).orElse(null); + stationReturn = input.read("StationReturn", Vec3.CODEC).orElse(null); + savedSeats.clear(); + List seats = input.listOrEmpty("Seats", Codec.STRING).stream().toList(); + for (int i = 0; i < seats.size(); i++) { + if (!seats.get(i).isEmpty()) { + try { + savedSeats.put(UUID.fromString(seats.get(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; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java new file mode 100644 index 00000000..eda5ed7b --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java new file mode 100644 index 00000000..4aaab44d --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java @@ -0,0 +1,193 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.UUIDUtil; +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.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 net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * A tow chain, {@code MCH_EntityChain}: it hangs above the vehicle that pulls and drags the towed + * one along, letting it drift up to the chain's length away and never more than two blocks past + * that. It goes when either end does. + */ +public class ChainEntity extends Entity { + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 15; + /** Ticks a chain loaded from disk waits for both ends to load before it gives up. */ + private static final int SEARCH_TICKS = 50; + + private static final EntityDataAccessor DATA_TOW = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TOWED = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + + private @Nullable UUID towId; + private @Nullable UUID towedId; + private @Nullable Entity tow; + private @Nullable Entity towed; + private int length = 4; + private int searchTicks = SEARCH_TICKS; + + public ChainEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + /** Chains {@code towed} to {@code tow}, at the distance they are apart now. Server only. */ + public static ChainEntity connect(Level level, Entity towed, Entity tow) { + ChainEntity chain = new ChainEntity(ModEntities.CHAIN.get(), level); + chain.setPos(tow.position().add(towed.position()).scale(0.5)); + chain.length = Mth.clamp((int) towed.distanceTo(tow), MIN_LENGTH, MAX_LENGTH); + chain.attach(towed, tow); + level.addFreshEntity(chain); + ModSounds.playAt(chain.tow, "chain_ct", 1.0F, 1.0F); + return chain; + } + + /** Whether {@code entity} can pull the other end: only vehicles can. */ + public static boolean canTow(Entity entity) { + return entity instanceof AircraftEntity; + } + + /** + * Only a vehicle pulls. Hooked the other way round, a container would hold a helicopter down, + * so the ends swap whichever order they were clicked in (and chains saved that way are fixed + * when they load). + */ + private void attach(Entity towed, Entity tow) { + if (!canTow(tow) && canTow(towed)) { + Entity swap = tow; + tow = towed; + towed = swap; + } + this.towed = towed; + this.tow = tow; + this.towedId = towed.getUUID(); + this.towId = tow.getUUID(); + entityData.set(DATA_TOWED, towed.getId()); + entityData.set(DATA_TOW, tow.getId()); + searchTicks = 0; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_TOW, -1); + builder.define(DATA_TOWED, -1); + } + + /** The end that pulls; on a client, as far as it has loaded. */ + public @Nullable Entity tow() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOW)) : tow; + } + + /** The end that is pulled. */ + public @Nullable Entity towed() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOWED)) : towed; + } + + /** Whether {@code entity} is either end of this chain. */ + public boolean holds(Entity entity) { + return entity == tow() || entity == towed(); + } + + /** Takes the chain off, with the sound of it coming loose. Server only. */ + public void disconnect() { + ModSounds.playAt(this, "chain_ct", 1.0F, 1.0F); + discard(); + } + + /** The chain on {@code entity}, at either end, if there is one within reach. */ + public static @Nullable ChainEntity on(Entity entity) { + for (ChainEntity chain : entity.level().getEntitiesOfClass(ChainEntity.class, entity.getBoundingBox().inflate(25.0))) { + if (chain.holds(entity)) { + return chain; + } + } + return null; + } + + @Override + public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel server)) { + return; + } + if (searchTicks > 0) { + searchTicks--; + Entity foundTowed = towedId != null ? server.getEntity(towedId) : null; + Entity foundTow = towId != null ? server.getEntity(towId) : null; + if (foundTowed != null && foundTow != null) { + attach(foundTowed, foundTow); + } + return; + } + if (tow == null || towed == null || tow.isRemoved() || towed.isRemoved() || tow == towed) { + disconnect(); + return; + } + towed.resetFallDistance(); + setPos(tow.getX(), tow.getY() + 2.0, tow.getZ()); + pull(tow, towed); + } + + /** {@code updateTowingEntityPosRot}: turns the towed end to face along the chain and drags it in. */ + private void pull(Entity tow, Entity towed) { + Vec3 d = towed.position().subtract(tow.position()); + double dist = d.length(); + if (dist <= length) { + return; + } + towed.setYRot((float) Math.toDegrees(Math.atan2(d.z, d.x)) + 90.0F); + double slack = dist - length; + towed.setDeltaMovement(towed.getDeltaMovement().subtract(d.scale(0.001 / slack))); + double max = length + 2.0; + if (dist > max) { + Vec3 at = tow.position().add(d.scale(max / dist)); + towed.setPos(at.x, at.y, at.z); + } + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + towId = input.read("Tow", UUIDUtil.CODEC).orElse(null); + towedId = input.read("Towed", UUIDUtil.CODEC).orElse(null); + length = Mth.clamp(input.getIntOr("Length", 4), MIN_LENGTH, MAX_LENGTH); + searchTicks = SEARCH_TICKS; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + if (towId != null && towedId != null) { + output.store("Tow", UUIDUtil.CODEC, towId); + output.store("Towed", UUIDUtil.CODEC, towedId); + } + output.putInt("Length", length); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java new file mode 100644 index 00000000..28f14851 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +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) { + } + + @Override + public void onPackSound(PackSoundPacket 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); + + void onPackSound(PackSoundPacket packet); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java new file mode 100644 index 00000000..76880875 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java @@ -0,0 +1,152 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.network.chat.Component; +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.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A cargo container, {@code MCH_EntityContainer}: a crate with a large chest's worth of room that + * sits on the ground, floats, can be pushed around, and rides the racks of the vehicles that carry + * {@code container}. Dropped from a rack high enough it comes down under a parachute. + */ +public class ContainerEntity extends Entity { + /** What racks call it in their {@code AddRack} lines. */ + public static final String CARGO_NAME = "container"; + private static final int SLOTS = 54; + /** Punches add up; the original broke it past 40 with 20 per point of damage. */ + private static final int BREAK_DAMAGE = 40; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final SimpleContainer items = new SimpleContainer(SLOTS); + private int damageTaken; + + public ContainerEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide() || isPassenger()) { + return; + } + if (damageTaken > 0) { + damageTaken--; + } + Vec3 m = getDeltaMovement(); + double my = m.y; + if (isInWater() || isInLava()) { + // Bobs up to the surface rather than sinking, as the original's boat physics did. + my = (my < 0.0 ? my / 2.0 : my) + (isEyeInFluid(FluidTags.WATER) ? 0.04 : 0.007); + } else { + my -= 0.04; + } + double mx = m.x; + double mz = m.z; + if (onGround()) { + mx *= 0.9; + mz *= 0.9; + } + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (player.getItemInHand(hand).getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (player.isSecondaryUseActive()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider((id, inventory, p) -> ChestMenu.sixRows(id, inventory, items), + Component.translatable("entity.mcheli.container"))); + } + return InteractionResult.SUCCESS; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || !(source.getEntity() instanceof Player player)) { + // Only players take it apart; explosions and stray rounds leave the cargo alone. + return false; + } + ModSounds.playAt(this, "hit", 1.0F, 1.3F); + damageTaken += (int) (damage * 20.0F); + boolean creative = player.getAbilities().instabuild; + if (creative || damageTaken > BREAK_DAMAGE) { + if (!creative) { + spawnAtLocation(level, ModItems.CONTAINER.get()); + } + Containers.dropContents(level, this, items); + discard(); + } + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean isPushable() { + return !isPassenger(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return !isPassenger(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.CONTAINER.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + items.clearContent(); + ContainerHelper.loadAllItems(input, items.getItems()); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + ContainerHelper.saveAllItems(output, items.getItems(), false); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java new file mode 100644 index 00000000..6b25a388 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java new file mode 100644 index 00000000..aa13dba7 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java @@ -0,0 +1,213 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.weapon.CasStrike; +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.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The ground laser target designator, {@code MCH_EntityGLTD}: a tripod a player sits behind. Its + * sight looks up to 70 degrees either side of where it was set up, zooms and has night vision, + * and marking a spot calls in an A-10 for three gun runs over it. + */ +public class GltdEntity extends Entity { + /** How far the sight turns from the tripod's heading, either way, and up or down. */ + public static final float VIEW_LIMIT = 70.0F; + /** The CAS weapon the A-10s fire; the original hard coded it too. */ + private static final String WEAPON = "a10gau8"; + /** Added to the weapon's own {@code Delay} between two calls. */ + private static final int EXTRA_WAIT = 150; + private static final double SIGHT_RANGE = 300.0; + + private static final EntityDataAccessor DATA_WAIT = SynchedEntityData.defineId(GltdEntity.class, EntityDataSerializers.INT); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final CasStrike strike = new CasStrike(WEAPON); + private boolean nightVision; + + public GltdEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WAIT, 0); + } + + /** Ticks until the next A-10 can be called; the sight shows it. */ + public int waitTicks() { + return entityData.get(DATA_WAIT); + } + + /** The eye of the sight, a little in front of and above the tripod. */ + public Vec3 sightPosition(float partialTicks) { + float yaw = getYRot(partialTicks) * Mth.DEG_TO_RAD; + Vec3 pos = getPosition(partialTicks); + return new Vec3(pos.x - Mth.sin(yaw) * 0.6, pos.y + 0.7, pos.z + Mth.cos(yaw) * 0.6); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.04, 0.0); + if (onGround()) { + m = m.scale(0.5); + } + move(MoverType.SELF, m); + setDeltaMovement(m.x * 0.99, m.y * 0.95, m.z * 0.99); + + if (waitTicks() > 0) { + entityData.set(DATA_WAIT, waitTicks() - 1); + } + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (def != null && level() instanceof ServerLevel server) { + strike.tick(server, def); + } + Entity viewer = getFirstPassenger(); + if (viewer == null) { + nightVision = false; + } else if (nightVision) { + NightVision.topUp(viewer); + } + } + + /** + * {@code useCurrentWeapon}: marks the block {@code player} looks at through the sight and calls + * the A-10 on it. Server only. + * + * @return whether the strike was called; the sight may be waiting, or see nothing in range + */ + public boolean callStrike(ServerPlayer player) { + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (getFirstPassenger() != player || waitTicks() > 0 || def == null || !(level() instanceof ServerLevel server)) { + return false; + } + Vec3 from = sightPosition(1.0F); + Vec3 to = from.add(player.getLookAngle().scale(SIGHT_RANGE)); + BlockHitResult hit = server.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + strike.start(hit.getLocation(), player.getYRot(), this, player); + entityData.set(DATA_WAIT, def.delay + EXTRA_WAIT); + ModSounds.playAt(server, getX(), getY(), getZ(), "gltd", 0.5F, 1.0F); + if (def.sound != null) { + ModSounds.playTo(player, def.sound, 1.0F, 1.0F); + } + return true; + } + + public void setNightVision(Player player, boolean on) { + if (getFirstPassenger() != player) { + return; + } + nightVision = on; + if (!on) { + NightVision.end(player); + } + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (player.isSecondaryUseActive() || isVehicle()) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + player.setYRot(getYRot()); + player.setXRot(0.0F); + player.startRiding(this); + } + return InteractionResult.SUCCESS; + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (!level().isClientSide() && nightVision) { + NightVision.end(passenger); + nightVision = false; + } + } + + /** The viewer sits behind the tripod. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, -0.5).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + /** The original clamped the viewer's head the same way every tick. */ + @Override + public void onPassengerTurned(Entity passenger) { + float yaw = getYRot() + Mth.clamp(Mth.wrapDegrees(passenger.getYRot() - getYRot()), -VIEW_LIMIT, VIEW_LIMIT); + passenger.setYRot(yaw); + passenger.setYHeadRot(yaw); + passenger.setXRot(Mth.clamp(passenger.getXRot(), -VIEW_LIMIT, VIEW_LIMIT)); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved()) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + if (!creative) { + spawnAtLocation(level, ModItems.GLTD.get()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), "hit", 1.0F, 1.0F); + discard(); + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.GLTD.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java new file mode 100644 index 00000000..30290141 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java new file mode 100644 index 00000000..957bf9ef --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -0,0 +1,553 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +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.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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.
  • + *
  • Blades fold and unfold one after another at the same pace on both sides, from the + * synced fold state, instead of each client stepping its own copy.
  • + *
+ */ +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 static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + + private double currentSpeed = MIN_SPEED; + private float rotorAngle; + public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; + + 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; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, input.getBooleanOr("FoldBlade", false) ? FOLDED : UNFOLDED); + } + + /** 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() && canUseBlades()) { + setHovering(true); + } + } + + private boolean isLevelEnough() { + return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; + } + + @Override + public boolean canSwitchGunnerMode() { + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); + } + + @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; + } + // Standing on its skids or wheels a helicopter stays level; the mouse only tilts it in the air. + if (!isHovering() && !onGround()) { + 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 (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } + 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; + tickBlades(def); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 00000000..f52c4227 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 00000000..f7a7db39 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java @@ -0,0 +1,188 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A parachute with someone hanging under it, {@code MCH_EntityParachute}. It sinks at about two + * blocks a second, the rider steers it with the forward key, and it folds away a moment after it + * touches the ground or the water. + */ +public class ParachuteEntity extends Entity { + /** What the parachute looks like and how it opens, the original's model type. */ + public enum Kind { + /** From the parachute item: open from the start. */ + ITEM, + /** An ejection seat: the rider falls in the seat and the canopy opens once it drops. */ + EJECTION_SEAT, + /** Troops dropped from a rack or a cargo door: open from the start, no seat. */ + DROP + } + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(ParachuteEntity.class, EntityDataSerializers.INT); + private static final double MAX_SPEED = 0.35; + private static final double MIN_STEER = 0.07; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private double steer = MIN_STEER; + private int onGroundCount; + /** Who boards on the next server tick; boarding in the same tick as the spawn can be lost. */ + private @Nullable Entity user; + + public ParachuteEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Opens a parachute above {@code user}, carrying over their speed, and puts them under it. + * Server only. + */ + public static ParachuteEntity open(Level level, Entity user, Kind kind) { + ParachuteEntity parachute = new ParachuteEntity(ModEntities.PARACHUTE.get(), level); + double lift = kind == Kind.ITEM ? 3.5 : 0.0; + parachute.setPos(user.getX(), user.getY() + lift, user.getZ()); + parachute.setYRot(user.getYRot()); + parachute.setDeltaMovement(user.getDeltaMovement()); + parachute.entityData.set(DATA_KIND, kind.ordinal()); + parachute.user = user; + user.resetFallDistance(); + level.addFreshEntity(parachute); + return parachute; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, Kind.ITEM.ordinal()); + } + + public Kind kind() { + Kind[] kinds = Kind.values(); + int i = entityData.get(DATA_KIND); + return i >= 0 && i < kinds.length ? kinds[i] : Kind.ITEM; + } + + /** An ejection seat only opens its canopy once it has started to fall. */ + public boolean isOpen() { + return kind() != Kind.EJECTION_SEAT || getDeltaMovement().y < -0.1; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + if (user != null) { + if (!user.isPassenger() && user.isAlive()) { + user.startRiding(this, true, true); + setYRot(user.getYRot()); + } + user = null; + } + Entity rider = getFirstPassenger(); + if (rider == null && tickCount > 20 || isInWater()) { + ejectPassengers(); + discard(); + return; + } + if (rider != null) { + rider.resetFallDistance(); + } + simulate(rider); + } + + /** {@code onUpdateServer}. */ + private void simulate(@Nullable Entity rider) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevSpeed = Math.sqrt(mx * mx + mz * mz); + boolean seatLaunch = kind() == Kind.EJECTION_SEAT && tickCount < 20; + my -= onGround() || seatLaunch ? 0.01 : 0.03; + + if (isOpen()) { + if (rider instanceof ServerPlayer player && player.getLastClientInput().forward()) { + double yaw = Math.toRadians(rider.getYRot()); + mx += -Math.sin(yaw) * steer * 0.05; + mz += Math.cos(yaw) * steer * 0.05; + } + double speed = Math.sqrt(mx * mx + mz * mz); + if (speed > MAX_SPEED) { + mx *= MAX_SPEED / speed; + mz *= MAX_SPEED / speed; + speed = MAX_SPEED; + } + // Holding course builds up speed; letting go settles back to a slow drift. + if (speed > prevSpeed) { + steer = Math.min(steer + (MAX_SPEED - steer) / 35.0, MAX_SPEED); + } else { + steer = Math.max(steer - (steer - MIN_STEER) / 35.0, MIN_STEER); + } + } + + if (onGround() && ++onGroundCount > 5) { + ejectPassengers(); + discard(); + return; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + my *= seatLaunch ? 0.95 : 0.9; + double drag = isOpen() ? 0.95 : 0.99; + setDeltaMovement(mx * drag, my, mz * drag); + if (rider != null) { + setYRot(rider.getYRot()); + } + } + + /** + * The server moves the parachute; clients ease towards each position it sends over three + * ticks, as for the vehicles. Without this a slow descent steps from one update to the next. + */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + entityData.set(DATA_KIND, input.getIntOr("Kind", Kind.ITEM.ordinal())); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("Kind", kind().ordinal()); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/PartState.java new file mode 100644 index 00000000..54755d56 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java new file mode 100644 index 00000000..b80171b8 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 00000000..e5781817 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,106 @@ +package com.jnhprojects.mcheli.entity; + +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java new file mode 100644 index 00000000..e729c7f5 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java new file mode 100644 index 00000000..92e8e59e --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 00000000..221c9563 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,339 @@ +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.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.chat.Component; +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.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

The original kept the player at the station and had the drone read its pilot from there. + * Here the pilot takes the drone's reserved seat for as long as they fly it, hidden and unhurt, + * so the drone uses the same controls, cameras, HUD and weapons as every other vehicle. Leaving + * the drone, or losing it, puts the pilot back beside the station. + */ +public class UavStationEntity extends Entity { + public static final int STATION = 1; + public static final int PORTABLE = 2; + /** Further out than this, measured flat, the link breaks and the drone is lost, as in the original. */ + public static final double RANGE = 123.0; + /** The launch offset goes up to this far from the station along each axis. */ + public static final int MAX_OFFSET = 50; + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_OPEN = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_OFFSET = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BLOCK_POS); + private static final EntityDataAccessor DATA_LINKED = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final SimpleContainer slot = new SimpleContainer(1); + private @Nullable UUID lastUav; + /** The lid of the portable controller, 0 shut to 1 open. */ + private float cover; + private float previousCover; + + public UavStationEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, (byte) STATION); + builder.define(DATA_OPEN, true); + builder.define(DATA_OFFSET, new BlockPos(12, 2, 12)); + builder.define(DATA_LINKED, false); + } + + public int kind() { + return entityData.get(DATA_KIND); + } + + public void setKind(int kind) { + entityData.set(DATA_KIND, (byte) (kind == PORTABLE ? PORTABLE : STATION)); + } + + public boolean isOpen() { + return entityData.get(DATA_OPEN); + } + + /** Where a launched drone appears, relative to the station. */ + public BlockPos offset() { + return entityData.get(DATA_OFFSET); + } + + public void setOffset(BlockPos offset) { + entityData.set(DATA_OFFSET, new BlockPos(Mth.clamp(offset.getX(), -MAX_OFFSET, MAX_OFFSET), + Mth.clamp(offset.getY(), -MAX_OFFSET, MAX_OFFSET), Mth.clamp(offset.getZ(), -MAX_OFFSET, MAX_OFFSET))); + } + + /** Whether a drone is flown from here right now; the screen lights up. */ + public boolean isLinked() { + return entityData.get(DATA_LINKED); + } + + public void setLinked(boolean linked) { + entityData.set(DATA_LINKED, linked); + } + + public SimpleContainer slot() { + return slot; + } + + public float cover(float partialTicks) { + return Mth.lerp(partialTicks, previousCover, cover); + } + + /** + * {@code initUavPostion}: the first launch offset is twelve blocks out on the diagonal the + * station faces, two up. + */ + public void initOffset() { + int quarter = (int) (Mth.positiveModulo(getYRot() + 45.0F, 360.0F) / 90.0F) % 4; + int x = quarter == 0 || quarter == 3 ? 12 : -12; + int z = quarter == 0 || quarter == 1 ? 12 : -12; + setOffset(new BlockPos(x, 2, z)); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + previousCover = cover; + cover = Mth.clamp(cover + (isOpen() ? 0.1F : -0.1F), 0.0F, 1.0F); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.03, 0.0); + move(MoverType.SELF, new Vec3(0.0, m.y, 0.0)); + setDeltaMovement(0.0, onGround() ? 0.0 : m.y * 0.96, 0.0); + + Entity user = getFirstPassenger(); + if (user instanceof ServerPlayer player && !slot.getItem(0).isEmpty()) { + launch(player, slot.getItem(0)); + } + } + + /** What the slot holds, if it is a vehicle at all. */ + public static @Nullable AircraftDefinition definitionOf(ItemStack stack) { + String name = AircraftItem.vehicleOf(stack); + return name != null ? MCHeli.content().any(name) : null; + } + + /** Whether this station can put up the vehicle in {@code stack}. */ + public boolean accepts(@Nullable AircraftDefinition def) { + return def != null && def.uav && (kind() != PORTABLE || def.smallUav); + } + + /** {@code handleItem}: puts up the drone in the slot and hands it to {@code player}. */ + private void launch(ServerPlayer player, ItemStack stack) { + AircraftDefinition def = definitionOf(stack); + if (!accepts(def) || !(level() instanceof ServerLevel server)) { + return; + } + if (ServerConfig.isDisabled(def.name())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName())); + return; + } + AircraftEntity uav = ModEntities.typeFor(def).create(server, EntitySpawnReason.TRIGGERED); + if (uav == null) { + return; + } + BlockPos o = offset(); + double y = Math.max(getY() + o.getY(), server.getMinY() + 2.0); + uav.setType(def); + uav.snapTo(getX() + o.getX(), y, getZ() + o.getZ(), getYRot() - 180.0F, 0.0F); + if (!server.noCollision(uav, uav.getBoundingBox().deflate(0.1))) { + player.sendOverlayMessage(Component.translatable("message.mcheli.uav.blocked")); + slot.setItem(0, ItemStack.EMPTY); + Containers.dropItemStack(server, player.getX(), player.getY(), player.getZ(), stack); + return; + } + slot.setItem(0, ItemStack.EMPTY); + uav.setFuelLevel(def.targetDrone ? uav.maxFuel() : (int) (uav.maxFuel() * 0.05F)); + server.addFreshEntity(uav); + if (!def.targetDrone) { + player.closeContainer(); + control(player, uav); + } + } + + /** {@code controlLastAircraft}: takes the last drone flown from here again. */ + public boolean continueControl(ServerPlayer player) { + AircraftEntity uav = lastUav(); + if (uav == null || getFirstPassenger() != player || !uav.canTakeRemotePilot()) { + return false; + } + player.closeContainer(); + return control(player, uav); + } + + public @Nullable AircraftEntity lastUav() { + if (lastUav == null || !(level() instanceof ServerLevel server)) { + return null; + } + Entity e = server.getEntity(lastUav); + return e instanceof AircraftEntity uav && uav.isAlive() && !uav.isDestroyed() && inRange(uav) ? uav : null; + } + + public boolean inRange(Entity uav) { + double dx = uav.getX() - getX(); + double dz = uav.getZ() - getZ(); + return dx * dx + dz * dz <= RANGE * RANGE; + } + + private boolean control(ServerPlayer player, AircraftEntity uav) { + player.stopRiding(); + if (!uav.takeRemotePilot(player, this)) { + return false; + } + lastUav = uav.getUUID(); + setLinked(true); + player.setYRot(uav.getYRot()); + return true; + } + + /** Where a pilot comes back to when the drone is left or lost: the seat in front of the station. */ + public Vec3 returnPosition() { + float yaw = getYRot() * Mth.DEG_TO_RAD; + return new Vec3(getX() - Mth.sin(yaw) * 0.9, getY(), getZ() + Mth.cos(yaw) * 0.9); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (isVehicle()) { + return InteractionResult.PASS; + } + if (kind() == PORTABLE && player.isSecondaryUseActive()) { + if (!level().isClientSide()) { + entityData.set(DATA_OPEN, !isOpen()); + } + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive() || kind() == PORTABLE && !isOpen()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer server) { + player.setYRot(getYRot() - 180.0F); + if (player.startRiding(this)) { + server.openMenu(new SimpleMenuProvider((id, inventory, p) -> new UavStationMenu(id, inventory, this), + Component.translatable(kind() == PORTABLE ? "item.mcheli.uav_station2" : "item.mcheli.uav_station"))); + } + } + return InteractionResult.SUCCESS; + } + + /** The original sat the player down in front of the console, lower for the portable one. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + double y = kind() == PORTABLE ? -0.6 : 0.35; + return new Vec3(0.0, y, 0.9).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || damage <= 0.0F) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + AircraftEntity uav = lastUav(); + if (uav != null) { + uav.releaseRemotePilot(); + } + Containers.dropContents(level, blockPosition(), slot); + if (!creative) { + spawnAtLocation(level, item()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), source.getEntity() instanceof Player ? "hit" : "helidmg", 1.0F, 1.0F); + discard(); + return true; + } + + private Item item() { + return kind() == PORTABLE ? ModItems.UAV_CONTROLLER.get() : ModItems.UAV_STATION.get(); + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return true; + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(item()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + setKind(input.getIntOr("Kind", STATION)); + entityData.set(DATA_OPEN, input.getBooleanOr("Open", true)); + setOffset(new BlockPos(input.getIntOr("UavX", 12), input.getIntOr("UavY", 2), input.getIntOr("UavZ", 12))); + lastUav = input.read("LastUav", UUIDUtil.CODEC).orElse(null); + slot.setItem(0, input.read("Item", ItemStack.CODEC).orElse(ItemStack.EMPTY)); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("Kind", kind()); + output.putBoolean("Open", isOpen()); + BlockPos o = offset(); + output.putInt("UavX", o.getX()); + output.putInt("UavY", o.getY()); + output.putInt("UavZ", o.getZ()); + if (lastUav != null) { + output.store("LastUav", UUIDUtil.CODEC, lastUav); + } + if (!slot.getItem(0).isEmpty()) { + output.store("Item", ItemStack.CODEC, slot.getItem(0)); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java new file mode 100644 index 00000000..1fdc978e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.supply.SupplyRules; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** + * What a vehicle carries besides its crew, {@code MCH_AircraftInventory} and the container half of + * {@code W_EntityContainer}: the equipment slots of the vehicle screen (three fuel cans and a + * parachute per ejection seat) and the cargo chest of vehicles with an {@code InventorySize}. + * + *

The cargo chest always has room for the largest size, 54 slots, and the screen shows as many + * rows as the definition asks for. Items in rows a content reload took away are kept rather than + * lost, and still drop when the vehicle is destroyed. + */ +public final class VehicleInventory { + public static final int FUEL_SLOTS = 3; + public static final int PARACHUTE_SLOTS = 2; + public static final int EQUIPMENT_SLOTS = FUEL_SLOTS + PARACHUTE_SLOTS; + public static final int CARGO_SLOTS = 54; + /** How far from the vehicle's centre a player outside may keep a screen open, past its width. */ + private static final double REACH = 12.0; + + private final AircraftEntity vehicle; + private final SimpleContainer equipment = new GuardedContainer(EQUIPMENT_SLOTS); + private final SimpleContainer cargo = new GuardedContainer(CARGO_SLOTS); + + VehicleInventory(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + public SimpleContainer equipment() { + return equipment; + } + + public SimpleContainer cargo() { + return cargo; + } + + /** {@code isUseableByPlayer}: the crew, or someone standing next to the vehicle. */ + public boolean stillValid(Player player) { + if (vehicle.isRemoved() || vehicle.definition() == null) { + return false; + } + if (player.getVehicle() == vehicle) { + return true; + } + double reach = REACH + vehicle.definition().bodyWidth / 2.0; + return player.distanceToSqr(vehicle) <= reach * reach; + } + + // Fuel + + /** + * {@code updateFuel}: every half second, while the vehicle stands on something, each fuel can + * in a fuel slot pours up to 100 units into the tank. Server only. + * + * @return whether any fuel went in + */ + boolean pourFuelCans(int ticks) { + if (ticks % SupplyRules.FUEL_SLOT_INTERVAL != 0 || !vehicle.canSupply()) { + return false; + } + boolean poured = false; + for (int slot = 0; slot < FUEL_SLOTS; slot++) { + ItemStack can = equipment.getItem(slot); + if (!(can.getItem() instanceof FuelItem)) { + continue; + } + int amount = SupplyRules.fuelFromCan(vehicle.maxFuel() - vehicle.fuel(), FuelItem.contents(can)); + if (amount > 0) { + vehicle.refuel(amount); + can.setDamageValue(can.getDamageValue() + amount); + poured = true; + } + } + if (poured) { + equipment.setChanged(); + } + return poured; + } + + // Parachutes + + /** Parachute slots the vehicle screen offers: one per ejection seat. */ + public static int parachuteSlots(AircraftDefinition def) { + return Math.min(def.ejectionSeats(), PARACHUTE_SLOTS); + } + + /** + * {@code consumeParachute}: takes one parachute out of the parachute slots for a crew member + * who bails out. + * + * @return false when there is none left + */ + boolean takeParachute() { + for (int slot = FUEL_SLOTS; slot < EQUIPMENT_SLOTS; slot++) { + if (equipment.getItem(slot).getItem() instanceof ParachuteItem) { + equipment.removeItem(slot, 1); + return true; + } + } + return false; + } + + // Saving and dropping + + void save(ValueOutput output) { + ContainerHelper.saveAllItems(output.child("Equipment"), equipment.getItems(), false); + ContainerHelper.saveAllItems(output.child("Cargo"), cargo.getItems(), false); + } + + void load(ValueInput input) { + equipment.clearContent(); + cargo.clearContent(); + input.child("Equipment").ifPresent(in -> ContainerHelper.loadAllItems(in, equipment.getItems())); + input.child("Cargo").ifPresent(in -> ContainerHelper.loadAllItems(in, cargo.getItems())); + } + + /** {@code setDead}: everything aboard falls out where the vehicle was. Server only. */ + void dropAll() { + Containers.dropContents(vehicle.level(), vehicle, equipment); + Containers.dropContents(vehicle.level(), vehicle, cargo); + } + + /** A container that only stays open for players {@link #stillValid(Player)} allows. */ + private final class GuardedContainer extends SimpleContainer { + GuardedContainer(int size) { + super(size); + } + + @Override + public boolean stillValid(Player player) { + return VehicleInventory.this.stillValid(player); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java new file mode 100644 index 00000000..46ed88a8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.ParticleSplash; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * The spray, dust and exhaust a vehicle throws up, all client side: + *

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java new file mode 100644 index 00000000..e72e952e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java @@ -0,0 +1,160 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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(), EntitySpawnReason.TRIGGERED); + 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 hurtServer(ServerLevel level, 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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java new file mode 100644 index 00000000..a9d41520 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java new file mode 100644 index 00000000..e5f46c96 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java new file mode 100644 index 00000000..d7031ecf --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java new file mode 100644 index 00000000..c758d75b --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java new file mode 100644 index 00000000..b2b418ac --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java new file mode 100644 index 00000000..69a2f674 --- /dev/null +++ b/minecraft-26.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.getMinY() || pos.getY() >= server.getMaxY()) { + 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-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java new file mode 100644 index 00000000..a564a1fc --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java @@ -0,0 +1,161 @@ +package com.jnhprojects.mcheli.entity.projectile; + +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.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.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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) { + snapTo(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 hurtServer(ServerLevel level, 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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java new file mode 100644 index 00000000..eb723310 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java @@ -0,0 +1,139 @@ +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.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; + +/** + * 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(rgb, 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(), EntitySpawnReason.TRIGGERED); + 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().getMaxY(); 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public String defaultModel() { + return "rocket"; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java new file mode 100644 index 00000000..30f171c5 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -0,0 +1,790 @@ +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.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.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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); + /** Who fired, for clients: their rounds must not stop on their own vehicle. */ + private static final EntityDataAccessor DATA_SHOOTER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_USER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + + /** 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; + /** Client only: the round hit something here and waits, hidden, for the server to remove it. */ + private boolean spent; + + 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); + builder.define(DATA_SHOOTER, 0); + builder.define(DATA_USER, 0); + } + + /** + * 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) { + snapTo(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; + setOwners(shooter, 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; + setOwners(parent.shooter, parent.user); + } + + public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + entityData.set(DATA_SHOOTER, shooter != null ? shooter.getId() : 0); + entityData.set(DATA_USER, user != null ? user.getId() : 0); + } + + 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 (spent) { + return; + } + 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().getMaxY() && !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(), EntitySpawnReason.TRIGGERED); + 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; + } + + /** Clients only learn who fired through synched data; look them up once it has arrived. */ + private void resolveOwners() { + if (shooter == null && entityData.get(DATA_SHOOTER) > 0) { + shooter = level().getEntity(entityData.get(DATA_SHOOTER)); + } + if (user == null && entityData.get(DATA_USER) > 0) { + user = level().getEntity(entityData.get(DATA_USER)); + } + } + + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ + public boolean canHit(Entity entity) { + if (level().isClientSide()) { + resolveOwners(); + } + 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); + } + if (piercing <= 0) { + // The server decides what the hit does; until its removal arrives the round stays + // where it struck instead of flying on through the hull. + spent = true; + setPos(hit.location()); + setDeltaMovement(Vec3.ZERO); + } + 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 hurtServer(ServerLevel level, 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; + } + + /** Client only: whether the round already hit something and should no longer be drawn. */ + public boolean isSpent() { + return spent; + } + + @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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putString("WeaponName", weaponName()); + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java new file mode 100644 index 00000000..c39fa6fe --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 00000000..479ead5a --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,200 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +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.util.ARGB; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = ARGB.redFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float g = ARGB.greenFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float b = ARGB.blueFloat(color) * 0.9F + random.nextFloat() * 0.1F; + if (ARGB.red(color) == ARGB.green(color)) { + g = r; + } + if (ARGB.red(color) == ARGB.blue(color)) { + b = r; + } + if (ARGB.green(color) == ARGB.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(ARGB.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java new file mode 100644 index 00000000..a89f7c01 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java new file mode 100644 index 00000000..a06fef1a --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 00000000..7b04e4a4 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,146 @@ +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.content.pack.PackManifest; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; +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.function.Consumer; + +/** + * 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; 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)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + 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); + } + + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, Consumer out, TooltipFlag flag) { + super.appendHoverText(stack, context, display, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.accept((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { + return InteractionResult.PASS; + } + + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + if (!level.isClientSide()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.unknown", String.valueOf(name))); + } + return InteractionResult.FAIL; + } + if (!level.isClientSide() && ServerConfig.isDisabled(def.name())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName())); + return InteractionResult.FAIL; + } + + AircraftEntity vehicle = ModEntities.typeFor(def).create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (vehicle == null) { + return InteractionResult.FAIL; + } + Vec3 at = blockHit.getLocation(); + vehicle.setType(def); + vehicle.snapTo(at.x, at.y, at.z, player.getYRot(), 0.0F); + if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { + return InteractionResult.FAIL; + } + + 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 InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 00000000..2f18676a --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.removed")); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName())); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.released")); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance))); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.no_vehicle")); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName())); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 00000000..efa8f295 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * The fuel can, {@code MCH_ItemFuel}. It holds 600 units; the durability bar shows how much is + * left. Using it refills it from the coal and charcoal in the inventory, as in the original. + * + *

A can in a fuel slot of the vehicle screen empties into the tank while the vehicle stands on + * something, as in the original. Not in the original: right-clicking a vehicle with the can pours + * up to 100 units at once, which saves opening the screen. + */ +public class FuelItem extends Item { + /** Units a full can holds, the original's maximum damage. */ + public static final int CAPACITY = 600; + /** Units poured into a vehicle per click: what the original moved per fuel slot per update. */ + private static final int POUR = 100; + private static final int PER_COAL = 100; + private static final int PER_CHARCOAL = 75; + + public FuelItem(Properties properties) { + super(properties.durability(CAPACITY)); + } + + public static int contents(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + /** Fills a vehicle's tank from the can. Server only. */ + public static InteractionResult pour(ItemStack stack, AircraftEntity vehicle, Player player) { + int added = vehicle.refuel(player.getAbilities().instabuild ? POUR : Math.min(contents(stack), POUR)); + if (added <= 0) { + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) { + stack.setDamageValue(stack.getDamageValue() + added); + } + vehicle.level().playSound(null, vehicle.getX(), vehicle.getY(), vehicle.getZ(), SoundEvents.BUCKET_EMPTY, SoundSource.NEUTRAL, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } + + /** Charcoal first, then coal, each one worth part of the can, until it is full. */ + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + if (!stack.isDamaged() || player.getAbilities().instabuild) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + refill(stack, player.getInventory(), Items.CHARCOAL, PER_CHARCOAL); + refill(stack, player.getInventory(), Items.COAL, PER_COAL); + } + return InteractionResult.SUCCESS; + } + + private static void refill(ItemStack can, Inventory inventory, Item coal, int worth) { + for (int slot = 0; slot < inventory.getContainerSize() && can.isDamaged(); slot++) { + ItemStack item = inventory.getItem(slot); + while (item.is(coal) && !item.isEmpty() && can.isDamaged()) { + can.setDamageValue(Math.max(can.getDamageValue() - worth, 0)); + item.shrink(1); + } + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 00000000..a83ff5bd --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 00000000..0a286838 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java @@ -0,0 +1,61 @@ +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.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +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, ServerLevel level, Entity owner, @Nullable EquipmentSlot slot) { + if (!(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-26.1/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 00000000..2af7b79b --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.Level; + +/** The parachute, {@code MCH_ItemParachute}: used in mid-air, it opens above the player. */ +public class ParachuteItem extends Item { + public ParachuteItem(Properties properties) { + super(properties.stacksTo(1)); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (player.isPassenger() || player.onGround()) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + ParachuteEntity.open(level, player, ParachuteEntity.Kind.ITEM); + player.getItemInHand(hand).consume(1, player); + } + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 00000000..34c524d5 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.snapTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 00000000..6a4fb996 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.SPYGLASS; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 00000000..df8f33f8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,123 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResult.FAIL; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } + + @Override + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return false; + } + int drawn = Math.max(getUseDuration(stack, entity) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + return true; + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 00000000..7e3bc1c8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 00000000..c2619049 --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 00000000..8ad8f03f --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * The wrench, {@code MCH_ItemWrench}. Right-clicking a damaged vehicle repairs 10 hit points, once + * a second, at one point of the wrench's durability. Sneaking switches the vehicle to its next + * paint scheme. + * + *

The original repaired while the use key was held; a click per repair works the same with + * today's item use, which stops as soon as the cursor leaves the hull. + */ +public class WrenchItem extends Item { + /** An iron tool's durability, the original's tool material. */ + private static final int DURABILITY = 250; + private static final int REPAIR = 10; + private static final int COOLDOWN = 20; + + public WrenchItem(Properties properties) { + super(properties.durability(DURABILITY)); + } + + /** Server only. */ + public static InteractionResult useOn(ItemStack stack, AircraftEntity vehicle, Player player, InteractionHand hand) { + if (player.isSecondaryUseActive()) { + return vehicle.nextTexture() ? InteractionResult.SUCCESS : InteractionResult.FAIL; + } + if (player.getCooldowns().isOnCooldown(stack) || !vehicle.repair(REPAIR)) { + return InteractionResult.FAIL; + } + player.getCooldowns().addCooldown(stack, COOLDOWN); + stack.hurtAndBreak(1, player, hand); + ModSounds.playAt(vehicle, "wrench", 1.0F, 0.9F + vehicle.getRandom().nextFloat() * 0.2F); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 00000000..ec1b4d89 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,148 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes in the player's recipe book and asks for one by its + * display id; the server finds the recipe, takes the ingredients from the inventory and puts the + * result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addStandardInventorySlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, RecipeDisplayId id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeManager.ServerDisplayInfo info = player.level().getServer().getRecipeManager().getRecipeFromDisplay(id); + if (info == null) { + return; + } + RecipeHolder holder = info.parent(); + if (!holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + List ingredients = recipe.placementInfo().ingredients(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.assemble(CraftingInput.EMPTY)); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 00000000..4c5f7bd8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addStandardInventorySlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java new file mode 100644 index 00000000..b2dba1e4 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java @@ -0,0 +1,217 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGuiContainer}: fuel slots, a parachute slot per ejection + * seat, reloading the weapons from the crew member's own items and the way into the cargo chest. + * + *

Only crew open it, so the client finds the vehicle as the one its player rides. The weapon + * rounds and which weapons can be reloaded right now come from the server through data slots, + * since a client only hears about the ammunition of the seat it sits in. + */ +public class VehicleMenu extends AbstractContainerMenu { + /** Weapons the screen can show; more than any vehicle in the original has. */ + public static final int MAX_WEAPONS = 32; + /** Button ids from zero up reload that weapon; this one opens the cargo chest. */ + public static final int BUTTON_CARGO = 1000; + + public static final int FUEL_X = 8; + public static final int FUEL_Y = 30; + public static final int PARACHUTE_X = 8; + public static final int PARACHUTE_Y = 66; + public static final int INVENTORY_Y = 102; + + private static final int DATA_RELOADABLE = MAX_WEAPONS; + private static final int DATA_SIZE = MAX_WEAPONS + 2; + + private final @Nullable AircraftEntity vehicle; + private final Container equipment; + private final ContainerData data; + private final int parachuteSlots; + private boolean supplied; + + /** The client side, from the open screen packet. */ + public VehicleMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof AircraftEntity v ? v : null, + new SimpleContainer(VehicleInventory.EQUIPMENT_SLOTS), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public VehicleMenu(int containerId, Inventory inventory, AircraftEntity vehicle) { + this(containerId, inventory, vehicle, vehicle.inventory().equipment(), new ServerData(vehicle, inventory.player)); + } + + private VehicleMenu(int containerId, Inventory inventory, @Nullable AircraftEntity vehicle, Container equipment, ContainerData data) { + super(ModMenus.VEHICLE.get(), containerId); + this.vehicle = vehicle; + this.equipment = equipment; + this.data = data; + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + parachuteSlots = def != null ? VehicleInventory.parachuteSlots(def) : 0; + checkContainerSize(equipment, VehicleInventory.EQUIPMENT_SLOTS); + + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + addSlot(new Slot(equipment, i, FUEL_X + i * 18, FUEL_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof FuelItem; + } + }); + } + for (int i = 0; i < VehicleInventory.PARACHUTE_SLOTS; i++) { + boolean active = i < parachuteSlots; + addSlot(new Slot(equipment, VehicleInventory.FUEL_SLOTS + i, PARACHUTE_X + i * 18, PARACHUTE_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return active && stack.getItem() instanceof ParachuteItem; + } + + @Override + public boolean isActive() { + return active; + } + }); + } + addStandardInventorySlots(inventory, 8, INVENTORY_Y); + addDataSlots(data); + } + + public @Nullable AircraftEntity vehicle() { + return vehicle; + } + + /** Parachute slots in use: one per ejection seat. */ + public int parachuteSlots() { + return parachuteSlots; + } + + /** Magazine and reserve of a weapon together, as the server last reported them. */ + public int rounds(int weapon) { + return weapon >= 0 && weapon < MAX_WEAPONS ? data.get(weapon) : 0; + } + + /** Whether the server would reload this weapon now: the vehicle is down and the items are there. */ + public boolean canReload(int weapon) { + if (weapon < 0 || weapon >= MAX_WEAPONS) { + return false; + } + int bits = (data.get(DATA_RELOADABLE) & 0xFFFF) | (data.get(DATA_RELOADABLE + 1) & 0xFFFF) << 16; + return (bits & 1 << weapon) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (vehicle == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CARGO) { + return vehicle.openCargo(server); + } + if (id >= 0 && id < MAX_WEAPONS && Resupply.supply(server, vehicle, id)) { + supplied = true; + return true; + } + return false; + } + + @Override + public boolean stillValid(Player player) { + return vehicle != null && equipment.stillValid(player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + int equipmentEnd = VehicleInventory.EQUIPMENT_SLOTS; + if (index < equipmentEnd) { + if (!moveItemStackTo(stack, equipmentEnd, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof FuelItem) { + if (!moveItemStackTo(stack, 0, VehicleInventory.FUEL_SLOTS, false)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof ParachuteItem && parachuteSlots > 0) { + int first = VehicleInventory.FUEL_SLOTS; + if (!moveItemStackTo(stack, first, first + parachuteSlots, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** As in the original, closing the screen after a resupply loads every magazine from its reserve. */ + @Override + public void removed(Player player) { + super.removed(player); + if (supplied && vehicle != null && vehicle.weapons() != null && !player.level().isClientSide()) { + vehicle.weapons().reloadAll(); + } + } + + /** Rounds per weapon and the reload bits, worked out on the server whenever the menu syncs. */ + private record ServerData(AircraftEntity vehicle, Player player) implements ContainerData { + @Override + public int get(int index) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons == null) { + return 0; + } + if (index < MAX_WEAPONS) { + WeaponSet set = weapons.set(index); + // Data slots travel as shorts. + return set != null ? Math.min(set.ammo() + set.restAmmo(), Short.MAX_VALUE) : 0; + } + int bits = 0; + if (player instanceof ServerPlayer server) { + for (int i = 0; i < Math.min(weapons.sets().size(), MAX_WEAPONS); i++) { + if (Resupply.canSupply(server, vehicle, i)) { + bits |= 1 << i; + } + } + } + return index == DATA_RELOADABLE ? bits & 0xFFFF : bits >>> 16; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java new file mode 100644 index 00000000..ba56ca3e --- /dev/null +++ b/minecraft-26.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.interact(sender, InteractionHand.MAIN_HAND, msg.location.subtract(vehicle.position())); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java new file mode 100644 index 00000000..1b565e21 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -0,0 +1,164 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +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, + /** Open the vehicle screen: fuel and parachute slots, reloading, the way to the cargo chest. */ + VEHICLE_SCREEN, + /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ + GUNNER_MODE, + /** Turn night vision on for the sender, in vehicles with {@code EnableNightVision}. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT + } + + 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 VEHICLE_SCREEN -> vehicle.openScreen(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 00000000..46294aa3 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +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.item.crafting.display.RecipeDisplayId; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe the player's recipe book knows by this display id. + */ +public record DraftingTablePacket(RecipeDisplayId recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + RecipeDisplayId.STREAM_CODEC.encode(buf, msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(RecipeDisplayId.STREAM_CODEC.decode(buf)); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 00000000..76090f2e --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +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 request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket 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 equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java new file mode 100644 index 00000000..279b1511 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java new file mode 100644 index 00000000..0056dcc0 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 00000000..d3f204c7 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,119 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.level(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED) + : ModEntities.AA_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java new file mode 100644 index 00000000..97d2cbf4 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 00000000..1de0bfc3 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +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 client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java new file mode 100644 index 00000000..26065705 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,90 @@ +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 = 11; + + /** 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<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), + new Serverbound<>(TestFlightPacket.TYPE, TestFlightPacket.STREAM_CODEC, TestFlightPacket::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), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + 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), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::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.1/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 00000000..89c1644d --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 00000000..ad7f0c85 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 00000000..50f10f74 --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..b25eba0a --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

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

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.nonCriticalIoPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 00000000..b3e53269 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,158 @@ +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); + } + PackSubmissions.onLogin(player); + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); + } + + /** The packs the server uses now, as the client should load them. */ + 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.nameAndId()) + || Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(player.createCommandSourceStack()); + } + + 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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java new file mode 100644 index 00000000..6426f060 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 00000000..1ee72286 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +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.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 00000000..14b2db42 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +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 button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java new file mode 100644 index 00000000..db12a390 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java @@ -0,0 +1,34 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.workshop.TestFlight; +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; + +/** The workshop's Test flight button: fly the vehicle just saved. The server checks who is asking. */ +public record TestFlightPacket(String vehicle) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("test_flight"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TestFlightPacket::encode, TestFlightPacket::decode); + private static final int MAX_NAME = 64; + + public static void encode(TestFlightPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.vehicle, MAX_NAME); + } + + public static TestFlightPacket decode(FriendlyByteBuf buf) { + return new TestFlightPacket(buf.readUtf(MAX_NAME)); + } + + public static void handle(TestFlightPacket msg, ServerPlayer sender) { + if (sender != null) { + TestFlight.start(sender, msg.vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java new file mode 100644 index 00000000..292f643e --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 00000000..63a3577f --- /dev/null +++ b/minecraft-26.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( + ExtraCodecs.RGB_COLOR_CODEC.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-26.1/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 00000000..29a0b0ef --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +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 puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + ExtraCodecs.RGB_COLOR_CODEC.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 00000000..dbf58f03 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/platform/Platform.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.platform; + +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * 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); + + /** A block entity type; vanilla's constructor is private on 26.1. */ + BlockEntityType blockEntityType(BiFunction factory, Block block); + + final class Holder { + private static volatile Platform instance; + + private Holder() { + } + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 00000000..3d1b9085 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 00000000..030a07dd --- /dev/null +++ b/minecraft-26.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.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.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 00000000..00d40aed --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,21 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +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.world.level.block.entity.BlockEntityType; + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> Platform.get().blockEntityType(DraftingTableBlockEntity::new, ModBlocks.DRAFTING_TABLE.get())); + + private ModBlockEntities() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 00000000..19995a92 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of().setId(key) + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java new file mode 100644 index 00000000..870aa8b8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -0,0 +1,72 @@ +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.item.ThrowableItem; +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, and one for + * the equipment and throwables. Those lists are 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)); + public static final Registered EQUIPMENT = Registration.register(Registries.CREATIVE_MODE_TAB, "equipment", + key -> Platform.get().creativeTab(key(VEHICLES)) + .title(Component.translatable("itemGroup.mcheli.equipment")) + .icon(() -> new ItemStack(ModItems.WRENCH.get())) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) + .build()); + + 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(), EQUIPMENT.get()); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 00000000..377a5783 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,34 @@ +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()); + + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + 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.1/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 00000000..b6686ccd --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,170 @@ +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.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.ThrowableEntity; +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)); + + public static final Registered> PARACHUTE = Registration.register(Registries.ENTITY_TYPE, "parachute", key -> EntityType.Builder + .of(ParachuteEntity::new, MobCategory.MISC) + .sized(1.5F, 0.6F) + .clientTrackingRange(10) + .updateInterval(1) + .build(key)); + + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key)); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key)); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key)); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key)); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .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.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 00000000..e7415dfd --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,102 @@ +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.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; +import com.jnhprojects.mcheli.item.WrenchItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; + +import java.util.List; +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. The + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. + */ +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"); + + public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties().setId(key))); + public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties().setId(key))); + public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties().setId(key))); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties().setId(key))); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties().setId(key))); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties().setId(key))); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties().setId(key).useBlockDescriptionPrefix())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties().setId(key))); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); + + 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) || EQUIPMENT.stream().anyMatch(e -> e.id().getPath().equals(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.1/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java new file mode 100644 index 00000000..0dac0052 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.MenuType; + +/** Our screens with slots. The cargo chest uses vanilla's chest menus and needs no entry here. */ +public final class ModMenus { + public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", + key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); + + private ModMenus() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 00000000..4ed53acd --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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; + } + }); + + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 00000000..4d869373 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 00000000..db56d298 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,116 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + /** The colours a team can have. */ + public static final List COLORS = Arrays.stream(ChatFormatting.values()).filter(ChatFormatting::isColor).toList(); + + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER); + } + + /** Switches the {@code pvp} game rule in every dimension and says which way it went. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !player.level().getGameRules().get(GameRules.PVP); + for (ServerLevel level : server.getAllLevels()) { + level.getGameRules().set(GameRules.PVP, pvp, server); + } + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.team.no_teams")); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.sendOverlayMessage(Component.translatable("message.mcheli.team.bad_name", trimmed)); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(COLORS.get(Math.floorMod(color, COLORS.size()))); + team.setAllowFriendlyFire(friendlyFire); + player.sendOverlayMessage(Component.translatable("message.mcheli.team.created", trimmed)); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + target.teleport(target.findRespawnPositionAndUseSpawnBlock(false, TeleportTransition.DO_NOTHING)); + } + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.sendOverlayMessage(Component.translatable("message.mcheli.team.destroyed", count)); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 00000000..e78278a8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,125 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + 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, EntitySpawnReason.TRIGGERED); + if (a10 == null) { + return; + } + a10.setPos(x, y, z); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java new file mode 100644 index 00000000..204fe93f --- /dev/null +++ b/minecraft-26.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.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 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 level)) { + return false; + } + if (target instanceof LivingEntity living) { + living.invulnerableTime = 0; + } + 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(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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java new file mode 100644 index 00000000..4f4b0dc8 --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java new file mode 100644 index 00000000..f7fe3e89 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -0,0 +1,217 @@ +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.EntitySpawnReason; +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.ServerExplosion; +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, EntitySpawnReason.TRIGGERED); + 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 = ServerExplosion.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-26.1/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java new file mode 100644 index 00000000..12ddc658 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java @@ -0,0 +1,195 @@ +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.entity.EntitySpawnReason; +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(), EntitySpawnReason.TRIGGERED); + 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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java new file mode 100644 index 00000000..b8142e92 --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java new file mode 100644 index 00000000..f763f73f --- /dev/null +++ b/minecraft-26.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.Identifier; +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; + }; + Identifier key = Identifier.tryParse(id.contains(":") ? id : "minecraft:" + id); + if (key == null) { + return null; + } + Item item = BuiltInRegistries.ITEM.getValue(key); + return item == Items.AIR ? null : item; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java new file mode 100644 index 00000000..b4007926 --- /dev/null +++ b/minecraft-26.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.Identifier; +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, Identifier.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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java new file mode 100644 index 00000000..533f5fba --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -0,0 +1,82 @@ +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 a crew member's own items, the {@code supplyAmmo} path of the + * original's vehicle screen. One supply costs the weapon's {@code Item} lines and adds + * {@code SuppliedNum} rounds; it only works while the vehicle stands on something. + */ +public final class Resupply { + private Resupply() { + } + + /** {@code canPlayerSupplyAmmo}: whether {@link #supply} would do anything right now. */ + public static boolean canSupply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.set(weapon) : null; + if (set == null || vehicle.isDestroyed() || player.getVehicle() != vehicle || !vehicle.canSupply()) { + return false; + } + if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { + return false; + } + return player.getAbilities().instabuild || canPay(player.getInventory(), set.definition()); + } + + /** Pays for and adds one supply of {@code weapon}. Server only. */ + public static boolean supply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + if (!canSupply(player, vehicle, weapon)) { + return false; + } + WeaponSet set = vehicle.weapons().set(weapon); + if (!player.getAbilities().instabuild) { + pay(player.getInventory(), set.definition()); + } + 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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java new file mode 100644 index 00000000..3f867744 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -0,0 +1,418 @@ +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.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.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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, EntitySpawnReason.TRIGGERED); + 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, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; + } + + private static void sound(WeaponSet set, Entity at, String name) { + if (set.soundAllowed) { + WeaponDefinition w = set.definition(); + float rnd = w.soundPitchRandom; + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + } + } + + // 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) { + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? soundName(set) : "ng"); + return true; + } + List spotted = new ArrayList<>(); + 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; + } + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); + } + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "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; + set.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); + set.casUser = shot.user; + set.casShooter = shot.shooter; + set.casStartTick = CasStrike.FIRST_PASS; + 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; + } + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); + set.casStartTick = set.ticksSinceShot(0) + 45; + set.casCount++; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java new file mode 100644 index 00000000..6a9f53cf --- /dev/null +++ b/minecraft-26.1/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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 00000000..b9331154 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +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 java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.level(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.level(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} diff --git a/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java new file mode 100644 index 00000000..85dee9f8 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -0,0 +1,597 @@ +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.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()) { + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); + } + } + 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(rgb, 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, 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(rgb, 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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java new file mode 100644 index 00000000..3f539030 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -0,0 +1,505 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +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.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; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; + + /** Aim relative to the hull, following the crew member who operates the weapon. */ + public float rotationYaw; + 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); + } + } + + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + + // 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/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java new file mode 100644 index 00000000..f5786b92 --- /dev/null +++ b/minecraft-26.1/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; + +/** + * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server + * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. + */ +public final class TestFlight { + /** How far up to look for room when the vehicle does not fit where the player stands. */ + private static final int MAX_RISE = 48; + + private TestFlight() { + } + + public static void start(ServerPlayer player, String vehicle) { + MinecraftServer server = player.level().getServer(); + if (!server.isSingleplayerOwner(player.nameAndId())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.workshop.test_flight_owner_only")); + return; + } + if (player.isPassenger()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.workshop.test_flight_riding")); + return; + } + ContentReload.reload(server); + AircraftDefinition def = MCHeli.content().any(vehicle); + if (def == null) { + player.sendSystemMessage(Component.translatable("message.mcheli.workshop.test_flight_no_vehicle", vehicle)); + MCHeli.packs().reportProblems(player::sendSystemMessage); + return; + } + ServerLevel level = player.level(); + AircraftEntity aircraft = ModEntities.typeFor(def).create(level, EntitySpawnReason.COMMAND); + if (aircraft == null) { + return; + } + aircraft.setType(def); + Vec3 start = player.position(); + for (int rise = 0; rise <= MAX_RISE; rise++) { + aircraft.snapTo(start.x, start.y + rise, start.z, player.getYRot(), 0.0F); + if (level.noCollision(aircraft, aircraft.getBoundingBox())) { + break; + } + } + aircraft.startTestFlight(start); + level.addFreshEntity(aircraft); + player.startRiding(aircraft, true, true); + } +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-26.1/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 00000000..82992af9 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/items/drafting_table.json b/minecraft-26.1/src/main/resources/assets/mcheli/items/drafting_table.json new file mode 100644 index 00000000..a3afc683 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/items/drafting_table.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "mcheli:block/drafting_table" + } +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/items/helicopter.json b/minecraft-26.1/src/main/resources/assets/mcheli/items/helicopter.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/items/helicopter.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/items/plane.json b/minecraft-26.1/src/main/resources/assets/mcheli/items/plane.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/items/plane.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/items/tank.json b/minecraft-26.1/src/main/resources/assets/mcheli/items/tank.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/items/tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/items/vehicle.json b/minecraft-26.1/src/main/resources/assets/mcheli/items/vehicle.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/items/vehicle.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-26.1/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 00000000..8dfa5bcc --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-26.1/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 00000000..296e5397 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-26.1/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 00000000..977381be --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/post_effect/thermal.json b/minecraft-26.1/src/main/resources/assets/mcheli/post_effect/thermal.json new file mode 100644 index 00000000..593e905d --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/post_effect/thermal.json @@ -0,0 +1,38 @@ +{ + "targets": { + "swap": {} + }, + "passes": [ + { + "vertex_shader": "minecraft:core/screenquad", + "fragment_shader": "mcheli:post/thermal", + "inputs": [ + { + "sampler_name": "In", + "target": "minecraft:main" + } + ], + "output": "swap" + }, + { + "vertex_shader": "minecraft:core/screenquad", + "fragment_shader": "minecraft:post/blit", + "inputs": [ + { + "sampler_name": "In", + "target": "swap" + } + ], + "uniforms": { + "BlitConfig": [ + { + "name": "ColorModulate", + "type": "vec4", + "value": [ 1.0, 1.0, 1.0, 1.0 ] + } + ] + }, + "output": "minecraft:main" + } + ] +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/shaders/post/thermal.fsh b/minecraft-26.1/src/main/resources/assets/mcheli/shaders/post/thermal.fsh new file mode 100644 index 00000000..24f82a83 --- /dev/null +++ b/minecraft-26.1/src/main/resources/assets/mcheli/shaders/post/thermal.fsh @@ -0,0 +1,37 @@ +#version 330 + +// Thermal view, white hot. A post shader only sees colours, so heat is faked: bright and warm +// colours (fire, lava, skin, lamps) read hot, the sky and water cold, and outlines are drawn +// where the brightness changes sharply, like the original's "pencil" shader. + +uniform sampler2D InSampler; + +in vec2 texCoord; + +layout(std140) uniform SamplerInfo { + vec2 OutSize; + vec2 InSize; +}; + +out vec4 fragColor; + +const vec3 Gray = vec3(0.3, 0.59, 0.11); + +float heat(vec3 c) { + float luma = dot(c, Gray); + float warmth = clamp(c.r - c.b, 0.0, 1.0); + return clamp(luma * 0.7 + warmth * 0.6, 0.0, 1.0); +} + +void main() { + vec2 oneTexel = 1.0 / InSize; + float c = heat(texture(InSampler, texCoord).rgb); + float l = heat(texture(InSampler, texCoord - vec2(oneTexel.x, 0.0)).rgb); + float r = heat(texture(InSampler, texCoord + vec2(oneTexel.x, 0.0)).rgb); + float u = heat(texture(InSampler, texCoord - vec2(0.0, oneTexel.y)).rgb); + float d = heat(texture(InSampler, texCoord + vec2(0.0, oneTexel.y)).rgb); + float edge = clamp(abs(r - l) + abs(d - u), 0.0, 1.0); + + float value = smoothstep(0.08, 0.85, c) + edge * 0.6; + fragColor = vec4(vec3(clamp(value, 0.0, 1.0)), 1.0); +} diff --git a/minecraft-26.1/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-26.1/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 00000000..339b9729 Binary files /dev/null and b/minecraft-26.1/src/main/resources/assets/mcheli/textures/particle/glow.png differ diff --git a/minecraft-26.1/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-26.1/src/main/resources/data/mcheli/damage_type/bullet.json new file mode 100644 index 00000000..92ee6766 --- /dev/null +++ b/minecraft-26.1/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.1/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json b/minecraft-26.1/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json new file mode 100644 index 00000000..610afa08 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/chain.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/chain.json new file mode 100644 index 00000000..484f5463 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/container.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/container.json new file mode 100644 index 00000000..4c3bdcd9 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "#c:chests/wooden", + "I": "#c:ingots/iron" + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/drafting_table.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/drafting_table.json new file mode 100644 index 00000000..f3d6324e --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "minecraft:crafting_table", + "F": "#minecraft:wooden_fences", + "P": "#minecraft:planks", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/fgm148.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fgm148.json new file mode 100644 index 00000000..c2c61fd6 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/fgm148_bullet.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fgm148_bullet.json new file mode 100644 index 00000000..c87b4800 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/fim92.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fim92.json new file mode 100644 index 00000000..33a068e1 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/fim92_bullet.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fim92_bullet.json new file mode 100644 index 00000000..c472fd6e --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/fuel.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fuel.json new file mode 100644 index 00000000..d076b041 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/fuel.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "C": "minecraft:coal", + "I": "#c:ingots/iron" + }, + "pattern": [ + "ICI", + "III" + ], + "result": { + "id": "mcheli:fuel" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/gltd.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/gltd.json new file mode 100644 index 00000000..104ac108 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": "#c:storage_blocks/iron", + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_green.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_green.json new file mode 100644 index 00000000..1046d9cf --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:green_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:item_model": "mcheli:m18_green", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_red.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_red.json new file mode 100644 index 00000000..f98ac51f --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:red_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:item_model": "mcheli:m18_red", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_violet.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_violet.json new file mode 100644 index 00000000..b05f48a7 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:purple_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:item_model": "mcheli:m18_violet", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_yellow.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_yellow.json new file mode 100644 index 00000000..57ecd202 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:yellow_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:item_model": "mcheli:m18_yellow", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/parachute.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/parachute.json new file mode 100644 index 00000000..69eab191 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/parachute.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "S": "#c:strings", + "W": "#minecraft:wool" + }, + "pattern": [ + "WWW", + "S S", + " W " + ], + "result": { + "id": "mcheli:parachute" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/rangefinder.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/rangefinder.json new file mode 100644 index 00000000..58a13385 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json new file mode 100644 index 00000000..d6bac29a --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + "mcheli:rangefinder", + "#c:dusts/redstone" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/uav_station.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/uav_station.json new file mode 100644 index 00000000..24b97d55 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:storage_blocks/redstone" + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/uav_station2.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/uav_station2.json new file mode 100644 index 00000000..74bf0d20 --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +} diff --git a/minecraft-26.1/src/main/resources/data/mcheli/recipe/wrench.json b/minecraft-26.1/src/main/resources/data/mcheli/recipe/wrench.json new file mode 100644 index 00000000..b8c9bcff --- /dev/null +++ b/minecraft-26.1/src/main/resources/data/mcheli/recipe/wrench.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + " I ", + " II", + "I " + ], + "result": { + "id": "mcheli:wrench" + } +} diff --git a/minecraft-26.1/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-26.1/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json new file mode 100644 index 00000000..1042db9e --- /dev/null +++ b/minecraft-26.1/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.1/src/main/resources/mcheli_logo.png b/minecraft-26.1/src/main/resources/mcheli_logo.png new file mode 100644 index 00000000..95c72198 Binary files /dev/null and b/minecraft-26.1/src/main/resources/mcheli_logo.png differ diff --git a/minecraft-26.1/src/main/resources/pack.mcmeta b/minecraft-26.1/src/main/resources/pack.mcmeta new file mode 100644 index 00000000..6c0bb58d --- /dev/null +++ b/minecraft-26.1/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "${mod_name} resources", + "min_format": 84, + "max_format": 101 + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 00000000..ff258847 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/CommonHooks.java new file mode 100644 index 00000000..2cf2f6a8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; +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.block.DispenserBlock; +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() { + } + + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + + /** + * 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. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. + */ + public static void playerLoggedIn(ServerPlayer player) { + PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(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.2/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ContentPacks.java new file mode 100644 index 00000000..d3bfdee5 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,187 @@ +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 com.jnhprojects.mcheli.content.pack.PendingPacks; +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.HashSet; +import java.util.List; +import java.util.Set; +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 static final String SOUNDS = "assets/mcheli/sounds/"; + + private final Path folder; + private final PendingPacks pending; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private volatile Set sounds = Set.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.pending = new PendingPacks(gameDir.resolve(MCHeli.MOD_ID).resolve("pending")); + this.legacy = legacy; + } + + /** {@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); + PendingPacks.cleanReplaced(folder); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + sounds = soundsOf(accepted); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + /** Sound names as {@code RuntimeAssets.soundFile} finds them: {@code heli.ogg} and {@code heli1.ogg} are both {@code heli}. */ + private static Set soundsOf(List packs) { + Set names = new HashSet<>(); + for (ContentPack pack : packs) { + for (String file : pack.files()) { + if (file.startsWith(SOUNDS) && file.endsWith(".ogg")) { + String name = file.substring(SOUNDS.length(), file.length() - ".ogg".length()); + names.add(name); + if (name.endsWith("1")) { + names.add(name.substring(0, name.length() - 1)); + } + } + } + } + return Set.copyOf(names); + } + + /** Whether a pack in use brought this sound, so that the sound manager has not heard of it. */ + public boolean hasSound(String name) { + return sounds.contains(name); + } + + /** What players have sent this server, waiting for an operator. */ + public PendingPacks pending() { + return pending; + } + + private static @Nullable String refusal(ContentPack pack, long size, long total) { + if (!ServerConfig.isPackAllowed(pack.id())) { + return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; + } + 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.2/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 00000000..7846b7bf --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/LegacyContent.java new file mode 100644 index 00000000..495bb502 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -0,0 +1,239 @@ +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.List; +import java.util.Locale; +import java.util.Optional; +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.MODERN; + + private final Path folder; + private final Path packRoot; + private @Nullable Component problem; + + 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); + } + + /** 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-26.2/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/MCHeli.java new file mode 100644 index 00000000..1a890651 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; +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.ModMenus; +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); + ModBlocks.init(); + ModBlockEntities.init(); + ModDataComponents.init(); + ModEntities.init(); + ModParticles.init(); + ModMenus.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-26.2/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 00000000..25a3aea8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,262 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Definition; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.network.PackSubmissions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * {@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
+ *   /mcheli give <players> <vehicle> [count]   (operators)
+ *   /mcheli reload                       re-read the definitions and packs (operators)
+ *   /mcheli pending                      packs players have sent, waiting (operators)
+ *   /mcheli approve <pack>               put a waiting pack in use (operators)
+ *   /mcheli reject <pack>                throw a waiting pack away (operators)
+ * 
+ */ +public final class McheliCommand { + private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); + private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + private static final DynamicCommandExceptionType UNKNOWN_PENDING = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.pending.unknown", name)); + + private McheliCommand() { + } + + 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(Commands.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(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::reload)) + .then(Commands.literal("pending") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::pending)) + .then(Commands.literal("approve") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::approve))) + .then(Commands.literal("reject") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("pack", StringArgumentType.word()) + .suggests(McheliCommand::suggestPending) + .executes(McheliCommand::reject)))); + } + + private static CompletableFuture suggestPending(CommandContext ctx, SuggestionsBuilder builder) { + try { + return SharedSuggestionProvider.suggest(MCHeli.packs().pending().list().stream().map(PendingPacks.Submission::id), builder); + } catch (IOException e) { + return builder.buildFuture(); + } + } + + private static int pending(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List waiting; + try { + waiting = MCHeli.packs().pending().list(); + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + if (waiting.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.none"), false); + } + for (PendingPacks.Submission submission : waiting) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.entry", submission.id(), submission.manifest().version(), + submission.manifest().author(), submission.submitter(), "%.1f".formatted(submission.size() / 1048576.0), + String.join(", ", submission.definitions())), false); + List replaced = replaced(submission); + if (!replaced.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.pending.replaces", String.join(", ", replaced)), false); + } + } + return waiting.size(); + } + + /** + * What going live would take the place of: a pack with the same id, and vehicles or weapons of + * another pack with the same file name. That is allowed, so it is the operator's call to make, + * but it should not come as a surprise. + */ + private static List replaced(PendingPacks.Submission submission) { + List names = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.id().equals(submission.id())) { + names.add("pack " + pack.id()); + } + } + ContentRegistry content = MCHeli.content(); + for (String path : submission.definitions()) { + String name = path.substring(path.lastIndexOf('/') + 1, path.length() - ".txt".length()); + Definition existing = path.startsWith("weapons/") ? content.weapon(name) : content.any(name); + if (existing != null && !existing.pack().equals(submission.id())) { + names.add(name + " (" + existing.pack() + ")"); + } + } + return names; + } + + private static PendingPacks.Submission waiting(CommandContext ctx) throws CommandSyntaxException { + String id = StringArgumentType.getString(ctx, "pack"); + PendingPacks.Submission submission; + try { + submission = MCHeli.packs().pending().find(id); + } catch (IOException e) { + submission = null; + } + if (submission == null) { + throw UNKNOWN_PENDING.create(id); + } + return submission; + } + + private static int approve(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + if (PackSubmissions.install(source.getServer(), submission)) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.approve.success", submission.id(), submission.submitter()), true); + return 1; + } + source.sendFailure(Component.translatable("commands.mcheli.approve.left_out", submission.id())); + MCHeli.packs().reportProblems(source::sendFailure); + } catch (IOException | PackException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.getMessage())); + } + return 0; + } + + private static int reject(CommandContext ctx) throws CommandSyntaxException { + CommandSourceStack source = ctx.getSource(); + PendingPacks.Submission submission = waiting(ctx); + try { + PackSubmissions.reject(source.getServer(), submission); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reject.success", submission.id(), submission.submitter()), true); + return 1; + } catch (IOException e) { + source.sendFailure(Component.translatable("commands.mcheli.pending.failed", e.toString())); + return 0; + } + } + + private static int status(CommandContext ctx) { + 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); + if (!pack.manifest().author().isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.author", pack.manifest().author()), false); + } + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + + 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-26.2/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ModSounds.java new file mode 100644 index 00000000..ed6eeb7e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ModSounds.java @@ -0,0 +1,64 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.core.Holder; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +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. + * + *

A sound a pack brought is in no {@code sounds.json}, so the sound manager would drop it as + * unknown. For those the server sends our own packet, and the client streams the file. + */ +public final class ModSounds { + /** How far a sound carries at a volume of one, and per volume above that, as in vanilla. */ + private static final float RANGE = 16.0F; + + private static final Map EVENTS = new ConcurrentHashMap<>(); + + private ModSounds() { + } + + public static SoundEvent get(String name) { + return EVENTS.computeIfAbsent(name, n -> SoundEvent.createVariableRangeEvent(Identifier.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) { + playAt(entity.level(), entity.getX(), entity.getY(), entity.getZ(), name, volume, pitch); + } + + /** Plays {@code name} at a position for everyone nearby. Call on the server. */ + public static void playAt(Level level, double x, double y, double z, String name, float volume, float pitch) { + if (level instanceof ServerLevel server && MCHeli.packs().hasSound(name)) { + ModNetwork.sendNear(server, x, y, z, RANGE * Math.max(volume, 1.0F), new PackSoundPacket(name, x, y, z, volume, pitch)); + } else { + level.playSound(null, x, y, z, get(name), SoundSource.NEUTRAL, volume, pitch); + } + } + + /** Plays {@code name} for one player only, such as a hit confirmation. */ + public static void playTo(ServerPlayer player, String name, float volume, float pitch) { + if (MCHeli.packs().hasSound(name)) { + ModNetwork.sendToPlayer(player, new PackSoundPacket(name, player.getX(), player.getY(), player.getZ(), volume, pitch)); + } else { + player.connection.send(new ClientboundSoundPacket(Holder.direct(get(name)), SoundSource.PLAYERS, + player.getX(), player.getY(), player.getZ(), volume, pitch, player.getRandom().nextLong())); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ServerConfig.java new file mode 100644 index 00000000..9db31738 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,172 @@ +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.BooleanValue INFINITE_FUEL; + 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; + private static final ConfigSpec.BooleanValue ALLOW_SUBMISSIONS; + private static final ConfigSpec.BooleanValue REQUIRE_APPROVAL; + + 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()); + INFINITE_FUEL = builder + .comment("Whether vehicles never run out of fuel. Original: InfinityFuel") + .define("infiniteFuel", false); + 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); + ALLOW_SUBMISSIONS = builder + .comment("Whether players may send this server packs they made in the workshop. They wait in mcheli/pending", + "until an operator runs /mcheli approve. Off, players can only get packs from the server's owner.") + .define("allowSubmissions", false); + REQUIRE_APPROVAL = builder + .comment("Whether a submitted pack waits for an operator. Off, any player can add vehicles and weapons for", + "everyone, and even replace existing ones, so only turn it off among people you trust. A pack taking", + "the id of a pack already in use always waits.") + .define("requireApproval", true); + builder.pop(); + + SPEC = builder.build(); + } + + 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(); + } + + public static boolean infiniteFuel() { + return SPEC.isLoaded() ? INFINITE_FUEL.get() : INFINITE_FUEL.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 allowSubmissions() { + return SPEC.isLoaded() ? ALLOW_SUBMISSIONS.get() : ALLOW_SUBMISSIONS.getDefault(); + } + + public static boolean requireApproval() { + return SPEC.isLoaded() ? REQUIRE_APPROVAL.get() : REQUIRE_APPROVAL.getDefault(); + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 00000000..8545d2b5 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,100 @@ +package com.jnhprojects.mcheli.block; + +import com.mojang.serialization.MapCodec; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + private static final MapCodec CODEC = simpleCodec(DraftingTableBlock::new); + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.column(16.0, 0.0, 15.0); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 00000000..56238f3f --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/CameraAngles.java new file mode 100644 index 00000000..ab62e120 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 00000000..70bcc23e --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java new file mode 100644 index 00000000..fe428514 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -0,0 +1,333 @@ +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.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.render.ParachuteRenderer; +import com.jnhprojects.mcheli.client.render.ProjectileRenderer; +import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; +import com.jnhprojects.mcheli.client.screen.VehicleScreen; +import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; +import com.jnhprojects.mcheli.client.workshop.WorkshopClient; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; +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.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.network.chat.Component; +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 net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; + +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 static boolean shaderProgramsAssigned; + + private ClientHooks() { + } + + /** Loads a post effect into the game renderer, whose own method is private on 26.2. */ + public interface PostEffects { + void load(Identifier effect); + } + + private static PostEffects postEffects = effect -> { + }; + + static PostEffects postEffects() { + return postEffects; + } + + /** During mod construction, on a client only. */ + public static void init(PostEffects effects) { + postEffects = effects; + 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); + renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, + BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); + } + + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + + /** On the main thread during client setup, once our menu types are registered. */ + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::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() { + if (!shaderProgramsAssigned) { + // On the first tick rather than at construction, so Iris has surely loaded by now. + shaderProgramsAssigned = true; + Glow.assignShaderPrograms(); + } + CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); + VehicleCamera.tick(); + WeaponController.onClientTick(); + HudRenderer.tick(); + EngineSoundPlayer.tick(); + TargetMarkers.tick(); + WorkshopClient.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.gui.screen() != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.gui.setScreen(new TeamManagerScreen()); + } else { + mc.player.sendOverlayMessage(Component.translatable("message.mcheli.team.operators_only")); + } + } + + /** 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); + GltdController.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; + } + } + + /** The field of view for this frame, narrowed while the player zooms through a sight. */ + public static float fov(float fov) { + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); + } + + /** Whether to skip drawing the first person hand this frame. */ + public static boolean hidesHand() { + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); + } + + /** 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() || GltdController.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(); + } + + /** The partial tick the camera entity is drawn at, for the loaders whose event does not carry it. */ + public static float cameraPartialTicks() { + Minecraft mc = Minecraft.getInstance(); + return mc.gameRenderer.mainCamera().getCameraEntityPartialTicks(mc.getDeltaTracker()); + } + + 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); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.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(); + WorkshopClient.loggedOut(); + Markers.clear(); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java new file mode 100644 index 00000000..d933e518 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,338 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +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 com.jnhprojects.mcheli.network.PackSoundPacket; +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. + */ +public final class ClientPacks implements ClientAccess.PackReceiver { + public static final ClientPacks INSTANCE = new ClientPacks(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + 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(); + } + } + + @Override + public void onPackSound(PackSoundPacket packet) { + Minecraft.getInstance().getSoundManager().play( + new OneShotSound(packet.sound(), packet.x(), packet.y(), packet.z(), packet.volume(), packet.pitch())); + } + + /** The packs the server sent or named, for the package browser. Empty when hosting the game. */ + public List serverPacks() { + return mounted; + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + 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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/CrewController.java new file mode 100644 index 00000000..32af6770 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -0,0 +1,482 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +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.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Input; +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; + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } + 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; + VehicleCamera.reset(); + } + + Input keys = player.input.keyPresses; + boolean jumpPressed = keys.jump() && !jumpWasDown; + jumpWasDown = keys.jump(); + boolean pilot = seat == 0 && vehicle.getControllingPassenger() == player; + + if (pilot) { + AircraftEntity.ControlInput input = vehicle.input(); + input.throttleUp = keys.forward(); + input.throttleDown = keys.backward(); + input.left = keys.left(); + input.right = keys.right(); + input.freeLook = isFreeLook(vehicle); + input.brake = vehicle instanceof TankEntity && keys.jump(); + 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.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } + 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 (looksThroughSight(vehicle, seat)) { + VehicleCamera.onZoomKey(player, vehicle); + } else if (pilot) { + send(vehicle, CrewActionPacket.Action.FOLD); + } + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } + } + while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { + if (!vehicle.isDestroyed()) { + send(vehicle, CrewActionPacket.Action.VEHICLE_SCREEN); + } + } + 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); + } + } + } + + /** + * Whether this seat sees through a sight that can zoom: a camera position or gunner sight, the + * pilot in gunner mode, or the pilot of a UAV. The zoom key folds the wings everywhere else. + */ + private static boolean looksThroughSight(AircraftEntity vehicle, int seat) { + AircraftDefinition def = vehicle.definition(); + if (def == null || seat < 0 || seat >= def.seatCount() || def.cameraZoom <= 1) { + return false; + } + return eyePosition(vehicle, def, seat) != null || vehicle.isGunnerMode() || def.uav; + } + + 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; + } + + 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-26.2/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 00000000..7d2f31db --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.gui.screen() == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.text(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.text(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.text(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java new file mode 100644 index 00000000..111759ac --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +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 KeyMapping.Category CATEGORY = KeyMapping.Category.register(Identifier.fromNamespaceAndPath(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); + /** Zooms through a sight, as the original Z key did; otherwise 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); + /** Fuel and parachute slots, reloading and the cargo chest. The original used R, which switches seats here. */ + public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); + /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ + public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, 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, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); + + private KeyBindings() { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 00000000..df6c23fc --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,238 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.gui.screen() == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.text(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(RenderPipelines.GUI_TEXTURED, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), + (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.text(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.text(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.text(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.text(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.text(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.text(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 00000000..80aafc99 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,155 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final Identifier SCOPE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(RenderPipelines.GUI_TEXTURED, SCOPE, (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushMatrix(); + g.pose().translate(w / 2.0F, h / 2.0F); + g.pose().scale(k, k); + drawReadouts(g, mc.font, player, stack); + g.pose().popMatrix(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphicsExtractor g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.text(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.text(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.text(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.text(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.text(font, ">", 30, y, WHITE); + } + g.text(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 00000000..7f7afd84 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,165 @@ +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.Identifier; +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(); + /** The pack open in the workshop, searched first whether the game has loaded it or not. */ + private static volatile @Nullable Path workshopRoot; + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + 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); + } + + /** Sets the {@code assets/mcheli} folder of the pack open in the workshop, or {@code null} for none. */ + public static void setWorkshopRoot(@Nullable Path folder) { + workshopRoot = folder; + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(Identifier id) throws IOException { + Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); + 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 Identifier texture(Identifier 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); + } + } + + /** Uploads one texture again, read fresh from disk: the workshop just replaced its file. */ + public static void refreshTexture(Identifier id) { + TEXTURES.put(id, upload(id)); + } + + private static boolean upload(Identifier id) { + try (InputStream in = open(id)) { + if (in == null) { + return false; + } + NativeImage image = NativeImage.read(in); + Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, 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) { + Path workshop = workshopRoot; + if (workshop != null) { + Path file = inRoot(workshop, path); + if (file != null) { + return file; + } + } + for (Path root : roots) { + Path file = inRoot(root, path); + if (file != null) { + return file; + } + } + return null; + } + + private static @Nullable Path inRoot(Path root, String path) { + Path file = root.resolve(path).normalize(); + return file.startsWith(root) && Files.isRegularFile(file) ? file : null; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 00000000..19a387a2 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,87 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3fc; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta.getGameTimeDeltaPartialTick(false); + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "â–¼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "â—†", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphicsExtractor g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.mainCamera(); + Vector3fc forward = camera.forwardVector(); + Vec3 offset = at.subtract(camera.position()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + Vec3 ndc = mc.gameRenderer.projectPointToScreen(at); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.centeredText(font, symbol, (int) x, (int) y - 4, color); + g.centeredText(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.centeredText(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.centeredText(font, "â—†", ex, ey - 4, color); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 00000000..de7ef3c4 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,127 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.CrewActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +import java.util.function.Consumer; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision and thermal view for those with {@code EnableNightVision}. Both + * reset whenever the player changes seat or leaves the vehicle. + */ +public final class VehicleCamera { + /** The original's camera modes; the HUD reads the ordinal as {@code cam_mode}. */ + public enum Mode { + NORMAL, + NIGHT_VISION, + /** Night vision seen through a white hot filter, the original's "pencil" shader mode. */ + THERMAL + } + + /** The post effect of the thermal view, {@code assets/mcheli/post_effect/thermal.json}. */ + private static final Identifier THERMAL_EFFECT = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "thermal"); + + private static float zoom = 1.0F; + private static Mode mode = Mode.NORMAL; + + private VehicleCamera() { + } + + public static float zoom() { + return zoom; + } + + public static Mode mode() { + return mode; + } + + /** Back to the plain view, on a seat change or after leaving the vehicle. */ + static void reset() { + zoom = 1.0F; + mode = Mode.NORMAL; + } + + /** + * Keeps the thermal filter in the player's post effects while the thermal view is on. The list + * is the one a server's post effect packet fills, so ours is put back if that replaces it. + */ + static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null) { + return; + } + boolean active = THERMAL_EFFECT.equals(mc.gameRenderer.currentPostEffect()); + if (mode == Mode.THERMAL && !active) { + ClientHooks.postEffects().load(THERMAL_EFFECT); + } else if (mode != Mode.THERMAL && active) { + mc.gameRenderer.clearPostEffect(); + } + } + + /** + * {@code zoomCamera}: each press doubles the zoom up to the vehicle's {@code CameraZoom}, and + * the press after that goes back to one. The caller checks that the seat looks through a sight. + */ + static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { + refuse(player); + return; + } + zoom = zoom >= max - 0.01F ? 1.0F : Math.min(zoom * 2.0F, max); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + + /** + * {@code switchCameraMode}: steps from the plain view to night vision, to thermal view and + * back. The server applies night vision as an effect in both of the last two; the thermal + * filter is drawn here. + */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { + Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; + if (mode == Mode.NORMAL || next == Mode.NORMAL) { + nightVision.accept(next != Mode.NORMAL); + } + mode = next; + player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 1.0F); + } + + /** The view's field of view, narrowed by the zoom. */ + public static float fov(float fov) { + return zoom > 1.0F ? fov / zoom : fov; + } + + /** {@code playSoundNG}: the key does nothing in this seat. */ + private static void refuse(LocalPlayer player) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java new file mode 100644 index 00000000..9507412f --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java new file mode 100644 index 00000000..2cfff278 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java @@ -0,0 +1,187 @@ +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(); + } + } + + if (mc.gui.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.mainCamera().position() : 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-26.2/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java new file mode 100644 index 00000000..c742003d --- /dev/null +++ b/minecraft-26.2/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.GuiGraphicsExtractor; +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(GuiGraphicsExtractor 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.text(font, Component.translatable("hud.mcheli.hp", vehicle.hp(), def.maxHp), x, y, hpColor); + y += 11; + + WeaponSet set = weapons.current(seat); + if (set != null) { + g.text(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.text(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.VEHICLE_SCREEN.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.text(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.text(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.text(font, flare, x, y, vehicle.isFlareActive() ? GREEN : WHITE); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java new file mode 100644 index 00000000..d45c753b --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java @@ -0,0 +1,165 @@ +package com.jnhprojects.mcheli.client.hud; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import org.joml.Matrix3x2fStack; + +/** + * The drawing primitives of {@code MCH_HudItem}, on the 26.x 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 GuiGraphicsExtractor g; + private final Font font; + + HudCanvas(GuiGraphicsExtractor g, Font font) { + this.g = g; + this.font = font; + } + + Matrix3x2fStack 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) { + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) x, (float) y); + pose.rotate((float) Math.toRadians(degrees)); + pose.translate((float) -x, (float) -y); + } + + void pop() { + g.pose().popMatrix(); + } + + 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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) x0, (float) y0); + pose.rotate((float) Math.atan2(dy, dx)); + pose.translate(0.0F, -LINE_WIDTH / 2.0F); + pose.scale((float) length, LINE_WIDTH); + g.fill(0, 0, 1, 1, color); + pose.popMatrix(); + } + + /** {@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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) left, (float) top); + pose.scale((float) width, (float) height); + g.fill(0, 0, 1, 1, color); + pose.popMatrix(); + } + + 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(Identifier 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; + } + Matrix3x2fStack pose = g.pose(); + pose.pushMatrix(); + pose.translate((float) (left + width / 2.0), (float) (top + height / 2.0)); + pose.rotate((float) Math.toRadians(rotation)); + pose.translate((float) (-width / 2.0), (float) (-height / 2.0)); + pose.scale((float) width, (float) height); + g.blit(texture, 0, 0, 1, 1, + (float) (u / textureWidth), (float) ((u + uWidth) / textureWidth), + (float) (v / textureHeight), (float) ((v + vHeight) / textureHeight)); + pose.popMatrix(); + } + + void text(String text, int x, int y, int color, boolean centered) { + if (centered) { + g.centeredText(font, text, x, y, color); + } else { + g.text(font, text, x, y, color); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java new file mode 100644 index 00000000..0115b4c3 --- /dev/null +++ b/minecraft-26.2/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.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.resources.Identifier; +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(GuiGraphicsExtractor 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 -> { + Identifier texture = Identifier.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-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java new file mode 100644 index 00000000..6e611a23 --- /dev/null +++ b/minecraft-26.2/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.Identifier; +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, Identifier 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-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java new file mode 100644 index 00000000..8bbe3a28 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -0,0 +1,373 @@ +package com.jnhprojects.mcheli.client.hud; + +import com.jnhprojects.mcheli.client.VehicleCamera; +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 read as their idle state for now: 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; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; + 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; + private int fuelWarnCount; + + /** 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.getMinY()), mob.getBlockZ()); + int blocks = 0; + for (; pos.getY() < level.getMaxY(); 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.getMinY(); 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().getOverworldClockTime(), 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, vehicle.fuelRatio()); + v.set(HudVariable.LOW_FUEL, lowFuel(vehicle)); + 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, VehicleCamera.mode().ordinal()); + v.set(HudVariable.CAM_ZOOM, VehicleCamera.zoom()); + 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); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); + } + + /** + * {@code isLowFuel}: below 10% the warning blinks, on for eight seconds out of every fourteen. + * Ticks with each update, as the original counted per frame. + */ + private boolean lowFuel(AircraftEntity vehicle) { + if (fuelWarnCount <= 0) { + fuelWarnCount = 280; + } + fuelWarnCount--; + return fuelWarnCount < 160 && vehicle.maxFuel() > 0 && vehicle.fuelRatio() < 0.1F; + } + + /** 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); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; + } else { + vars.set(HudVariable.IS_HEAT_WPN, 0.0); + vars.set(HudVariable.RELOADING, 0.0); + vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; + } + 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, mortarDistance); + } + + 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().getOverworldClockTime() + 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 -> mortarDistance; + case MC_VER -> "26.2"; + 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 -> VehicleCamera.zoom(); + case UAV_DIST -> Math.max(vehicle.stationDistance(), 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-26.2/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 00000000..a09d9b2e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java @@ -0,0 +1,103 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +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.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.level.QuadParticleRenderState; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +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 SingleQuadParticle { + 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, 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; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.0F; + } + + @Override + public void tick() { + super.tick(); + if (flicker) { + waver = 0.7F + random.nextFloat() * 0.3F; + } + } + + @Override + public void extract(QuadParticleRenderState state, 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.extract(state, 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 getLightCoords(float partialTicks) { + return LightCoordsUtil.FULL_BRIGHT; + } + + @Override + protected Layer getLayer() { + 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, RandomSource random) { + 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.first(), strength); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 00000000..ff6c3e73 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.level.QuadParticleRenderState; +import net.minecraft.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends SingleQuadParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z, sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void extract(QuadParticleRenderState state, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.extract(state, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + protected Layer getLayer() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd, RandomSource random) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, random); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java new file mode 100644 index 00000000..938be6d9 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java @@ -0,0 +1,59 @@ +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** The close air support A-10, {@code MCH_RenderA10}. */ +public class A10Renderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/bullets/a10.png"); + + public A10Renderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 10.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + boolean visible; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(A10Entity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = entity.isVisible(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.get("", "a-10") : null; + if (mesh != null) { + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(-state.pitch)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutoutCull(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java new file mode 100644 index 00000000..d2dd770d --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -0,0 +1,77 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import org.jspecify.annotations.Nullable; + +/** + * Snapshot of a vehicle for one frame, filled in by {@link AircraftRenderer#extractRenderState}. + * Every angle is already interpolated for the frame's partial tick. + */ +public class AircraftRenderState extends EntityRenderState { + /** 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; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; + public float throttle; + + // Part states, 0 to 90 degrees + public float canopy; + public float landingGear; + public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; + 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; + /** Drawn inside a screen, as the workshop does, rather than in the world. */ + public boolean inGui; + + /** Per {@code AddPartWeapon} line, the state of the weapon it belongs to; see {@link WeaponPartState}. */ + public WeaponPartState[] weaponParts = new WeaponPartState[0]; + /** 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-26.2/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java new file mode 100644 index 00000000..b50ad369 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -0,0 +1,342 @@ +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.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; +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.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.debug.DebugScreenEntries; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.gizmos.GizmoStyle; +import net.minecraft.gizmos.Gizmos; +import net.minecraft.resources.Identifier; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * 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 { + private static final int HULL_COLOR = 0xFF00E0FF; + private static final int GENERATED_COLOR = 0xFFFFC000; + /** Ropes take the colour of brown wool. */ + private static final Identifier ROPE = Identifier.withDefaultNamespace("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; + + public AircraftRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 2.0F; + } + + /** + * With F3+B on, outlines the hull boxes next to vanilla's main box: what projectiles hit and + * what other vehicles collide with. The definition's own boxes are cyan, the ones generated + * from the model amber. + */ + private static void showHull(AircraftEntity entity, AircraftDefinition def, float partialTicks) { + List hull = entity.hullBoxes(); + if (hull.size() < 2) { + return; + } + Vec3 offset = entity.getPosition(partialTicks).subtract(entity.position()); + int listed = def.boundingBoxes.size(); + try (Gizmos.TemporaryCollection ignored = Minecraft.getInstance().levelRenderer.collectPerFrameRenderThreadGizmos()) { + // The first is the main box, which vanilla draws itself; then the listed boxes, then the generated ones. + for (int i = 1; i < hull.size(); i++) { + Gizmos.cuboid(hull.get(i).move(offset), GizmoStyle.stroke(i <= listed ? HULL_COLOR : GENERATED_COLOR)); + } + } + } + + @Override + public AircraftRenderState createRenderState() { + return new AircraftRenderState(); + } + + @Override + public void extractRenderState(T entity, AircraftRenderState state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + 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; + } + if (Minecraft.getInstance().debugEntries.isCurrentlyEnabled(DebugScreenEntries.ENTITY_HITBOXES)) { + showHull(entity, def, partialTicks); + } + + 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 HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getXRot(partialTicks) : state.pitch; + } + 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(LightCoordsUtil.sky(lightCoords) - entity.level().getSkyDarken(), 0); + int light = Math.max(LightCoordsUtil.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 + protected AABB getBoundingBoxForCulling(T entity) { + // The hit box is far smaller than most models, so cull on a generous box instead. + 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 submit(AircraftRenderState state, PoseStack poseStack, SubmitNodeCollector collector, CameraRenderState camera) { + 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, collector, texture(def.folder(), state.texture), state.lightCoords, + OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, collector); + } + if (state.searchLight) { + searchLights(def, state, poseStack, collector); + } + poseStack.popPose(); + } + super.submit(state, poseStack, collector, camera); + } + + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.mulPose(Axis.YN.rotationDegrees(state.searchLightYaw - state.yaw + light.yaw())); + pose.mulPose(Axis.XP.rotationDegrees(state.searchLightPitch + 90.0F - state.pitch + light.pitch())); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.mulPose(Axis.YN.rotationDegrees(light.yaw() + steer)); + pose.mulPose(Axis.XP.rotationDegrees(90.0F + light.pitch())); + } + Glow.cone(pose, collector, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + int light = state.lightCoords; + float length = state.ropeLength; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(ROPE), (p, buffer) -> { + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + }); + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.addVertex(p, x, y, z).setColor(0xFFFFFFFF).setUv(u, v).setOverlay(OverlayTexture.NO_OVERLAY).setLight(light).setNormal(p, 0.0F, 1.0F, 0.0F); + } + + private static Identifier texture(String folder, String name) { + return RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 00000000..5d7f43b7 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + /** Towed end, relative to the chain entity. */ + Vec3 from = Vec3.ZERO; + /** From the towed end to the end that pulls. */ + Vec3 span = Vec3.ZERO; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + protected AABB getBoundingBoxForCulling(ChainEntity chain) { + AABB box = super.getBoundingBoxForCulling(chain); + Entity towed = chain.towed(); + return towed != null ? box.minmax(towed.getBoundingBox()) : box; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ChainEntity chain, State state, float partialTicks) { + super.extractRenderState(chain, state, partialTicks); + Entity tow = chain.tow(); + Entity towed = chain.towed(); + if (tow == null || towed == null) { + state.span = Vec3.ZERO; + return; + } + Vec3 here = chain.getPosition(partialTicks); + Vec3 start = towed.getPosition(partialTicks); + state.from = start.subtract(here); + state.span = tow.getPosition(partialTicks).subtract(start); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + double length = state.span.length(); + if (mesh != null && length > LINK) { + Vec3 step = state.span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + int light = state.lightCoords; + pose.pushPose(); + pose.translate(state.from); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-yaw)); + pose.mulPose(Axis.ZP.rotationDegrees(pitch)); + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, GREY)); + pose.popPose(); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 00000000..665893c1 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + public static class State extends BlockEntityRenderState { + int rotation; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(DraftingTableBlockEntity table, State state, float partialTicks, Vec3 cameraPosition, + ModelFeatureRenderer.@Nullable CrumblingOverlay breakProgress) { + BlockEntityRenderer.super.extractRenderState(table, state, partialTicks, cameraPosition, breakProgress); + state.rotation = table.getBlockState().getValue(DraftingTableBlock.ROTATION); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.mulPose(Axis.YP.rotationDegrees(-state.rotation * 45.0F + 180.0F)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + pose.popPose(); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 00000000..80ca1e3b --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final Identifier texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(T entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.pitch)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, tint)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 00000000..66cffb45 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java @@ -0,0 +1,50 @@ +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.world.phys.Vec3; + +/** + * 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; + } + + public static class State extends EntityRenderState { + float glow; + float flicker = 1.0F; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(FlareEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.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. + state.flicker = 0.8F + 0.2F * (float) Math.sin((entity.tickCount + partialTicks) * 1.7F + entity.getId()); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * state.flicker, GlowParticleOptions.FIRE, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * 0.35F, GlowParticleOptions.FLASH, state.glow); + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java new file mode 100644 index 00000000..d2a46857 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.resources.Identifier; +import org.slf4j.Logger; +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 final Map, Map, List>> buckets = new IdentityHashMap<>(); + + private GlassSplit() { + } + + /** Called from the render thread only. */ + Split split(List groups, Identifier texture) { + if (groups.isEmpty()) { + return Split.EMPTY; + } + return splits.computeIfAbsent(groups, g -> new HashMap<>()).computeIfAbsent(texture, t -> compute(groups, t)); + } + + /** + * A part's objects by the texture each draws with, for a vehicle with {@code PartTexture} lines. + * The lists stay the same objects from frame to frame, so {@link #split} can remember them too. + * Called from the render thread only. + */ + List buckets(List groups, Map overrides) { + return buckets.computeIfAbsent(groups, g -> new HashMap<>()) + .computeIfAbsent(Map.copyOf(overrides), o -> PartTextures.assign(groups, o)); + } + + void clear() { + textures.clear(); + splits.clear(); + buckets.clear(); + } + + private Split compute(List groups, Identifier texture) { + 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(Identifier 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.getPixel(x, y) >>> 24); + } + } + return new Alpha(w, h, values); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 00000000..dab6b08e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,213 @@ +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.PrimitiveTopology; +import com.mojang.blaze3d.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.ColorTargetState; +import com.mojang.blaze3d.pipeline.DepthStencilState; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.platform.CompareOp; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.renderer.BindGroupLayouts; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; + +/** + * 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. + * With order independent transparency on, the vanilla translucent pipelines stand in. + */ +public final class Glow { + /** A white disc fading to nothing at the rim; also the {@code mcheli:glow} particle sprite. */ + public static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/particle/glow.png"); + + private static final DepthStencilState TEST_ONLY = new DepthStencilState(CompareOp.GREATER_THAN_OR_EQUAL, false); + + private static final RenderPipeline PARTICLE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow_particle")) + .withColorTargetState(new ColorTargetState(BlendFunction.LIGHTNING)) + .withDepthStencilState(TEST_ONLY) + .build(); + + /** + * Vanilla's translucent particles with depth writes off. A puff's clear edge would otherwise + * hide the puffs drawn after it, and a smoke cloud is mostly overlapping puffs. + */ + private static final RenderPipeline SMOKE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/smoke_particle")) + .withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)) + .withDepthStencilState(TEST_ONLY) + .build(); + + private static final RenderPipeline ENTITY_PIPELINE = RenderPipeline.builder(RenderPipelines.MATRICES_FOG_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow")) + .withVertexShader("core/entity") + .withFragmentShader("core/entity") + .withShaderDefine("EMISSIVE") + .withShaderDefine("NO_OVERLAY") + .withShaderDefine("NO_CARDINAL_LIGHTING") + .withBindGroupLayout(BindGroupLayouts.SAMPLER0) + .withVertexBinding(0, DefaultVertexFormat.ENTITY) + .withPrimitiveTopology(PrimitiveTopology.QUADS) + .withColorTargetState(new ColorTargetState(BlendFunction.LIGHTNING)) + .withDepthStencilState(TEST_ONLY) + .build(); + + /** The particle layer for {@code mcheli:glow}. */ + @SuppressWarnings("deprecation") // Vanilla's own particle layers still name the atlas this way. + public static final SingleQuadParticle.Layer PARTICLE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, PARTICLE_PIPELINE); + + /** The particle layer for {@code mcheli:smoke}. */ + @SuppressWarnings("deprecation") + public static final SingleQuadParticle.Layer SMOKE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, SMOKE_PIPELINE); + + private static final RenderType HALO = RenderType.create(MCHeli.MOD_ID + "_glow", RenderSetup.builder(ENTITY_PIPELINE) + .withTexture("Sampler0", TEXTURE) + .sortOnUpload() + .createRenderSetup()); + + private static final int FULL_BRIGHT = LightCoordsUtil.FULL_BRIGHT; + + private Glow() { + } + + /** + * Hands our pipelines to Iris, so a shader pack draws them with a matching program: the halos + * and tracers as emissive entities, the glow and smoke particles as translucent particles. + * Called once the mods are loaded; without Iris it does nothing. + */ + public static void assignShaderPrograms() { + IrisCompat.assign(ENTITY_PIPELINE, "EMISSIVE_ENTITIES"); + IrisCompat.assign(PARTICLE_PIPELINE, "PARTICLES_TRANSLUCENT"); + IrisCompat.assign(SMOKE_PIPELINE, "PARTICLES_TRANSLUCENT"); + } + + /** + * 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 color RGB; {@code strength} becomes the alpha, which scales the light added + */ + public static void halo(PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera, Vec3 offset, float radius, int color, + float strength) { + if (strength <= 0.0F || radius <= 0.0F) { + return; + } + int argb = ARGB.color(alpha(strength), color); + pose.pushPose(); + pose.translate(offset); + pose.mulPose(camera.orientation); + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // 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, SubmitNodeCollector collector, 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 = ARGB.color(alpha(strength), color); + int end = ARGB.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; + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // 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); + }); + } + + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, SubmitNodeCollector collector, float height, float radius, int start, int end) { + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + }); + } + + 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); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 00000000..02cc82ba --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +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.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float headYaw; + float headPitch; + boolean drawHead = true; + boolean laser; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(GltdEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + state.laser = viewer != null; + state.drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + state.headYaw = viewer != null ? viewer.getYRot(partialTicks) : state.yaw; + state.headPitch = viewer != null ? viewer.getXRot(partialTicks) : 0.0F; + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + int light = state.lightCoords; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + draw(mesh.part("$body"), pose, collector, light); + pose.popPose(); + if (state.drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.mulPose(Axis.YP.rotationDegrees(-state.headYaw)); + pose.mulPose(Axis.XP.rotationDegrees(state.headPitch)); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, collector, light); + pose.popPose(); + } + } + if (state.laser) { + float yaw = state.headYaw * Mth.DEG_TO_RAD; + float pitch = state.headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = new Vec3(state.x, state.y, state.z).add(head).subtract(camera.pos); + pose.pushPose(); + pose.translate(head); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, collector, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static void draw(List groups, PoseStack pose, SubmitNodeCollector collector, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java new file mode 100644 index 00000000..3803102e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java @@ -0,0 +1,49 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.blaze3d.pipeline.RenderPipeline; + +import java.lang.reflect.Method; + +/** + * Tells Iris which shader program draws our own render pipelines. Iris only knows the vanilla + * pipelines and draws any other with a program whose vertex layout does not match, which throws + * the vertices far off: the glows turn into long coloured lines running to the horizon. Iris is + * optional, so its API is looked up by reflection instead of compiled against. + */ +final class IrisCompat { + private static final String API = "net.irisshaders.iris.api.v0.IrisApi"; + private static final String PROGRAM = "net.irisshaders.iris.api.v0.IrisProgram"; + + private IrisCompat() { + } + + /** + * Assigns {@code pipeline} to the Iris program named {@code program}, such as + * {@code EMISSIVE_ENTITIES}. Does nothing without Iris. + * + * @return whether Iris took it + */ + static boolean assign(RenderPipeline pipeline, String program) { + Class api; + try { + api = Class.forName(API); + } catch (ClassNotFoundException absent) { + MCHeli.LOGGER.debug("Iris is not installed, {} keeps its own shader", pipeline.getLocation()); + return false; + } + try { + Class programs = Class.forName(PROGRAM); + Object instance = api.getMethod("getInstance").invoke(null); + Object target = programs.getField(program).get(null); + Method assign = api.getMethod("assignPipeline", RenderPipeline.class, programs); + assign.invoke(instance, pipeline, target); + MCHeli.LOGGER.debug("Iris draws {} with its {} program", pipeline.getLocation(), program); + return true; + } catch (ReflectiveOperationException | LinkageError e) { + // An Iris without this API: glows may look wrong under a shader pack, nothing worse. + MCHeli.LOGGER.warn("Could not hand {} to Iris, glows may look wrong with shaders: {}", pipeline.getLocation(), e.toString()); + return false; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java new file mode 100644 index 00000000..57dad7e5 --- /dev/null +++ b/minecraft-26.2/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.Identifier; +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) { + Identifier id = Identifier.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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java new file mode 100644 index 00000000..47cc66e3 --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 00000000..15ff27a2 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * {@code MCH_RenderParachute}: the item's round canopy, or the ejection seat with its canopy, + * from the models the import brought in. + */ +public class ParachuteRenderer extends EntityRenderer { + private static final Identifier ROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute1.png"); + private static final Identifier SEAT = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/parachute2.png"); + + public ParachuteRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + ParachuteEntity.Kind kind = ParachuteEntity.Kind.ITEM; + boolean open; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ParachuteEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.open = entity.isOpen(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + List groups = groups(state); + if (!groups.isEmpty()) { + Identifier texture = state.kind == ParachuteEntity.Kind.ITEM ? ROUND : SEAT; + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static List groups(State state) { + if (state.kind == ParachuteEntity.Kind.ITEM) { + Mesh mesh = MeshCache.INSTANCE.get("", "parachute1"); + return mesh != null ? mesh.groups() : List.of(); + } + Mesh mesh = MeshCache.INSTANCE.get("", "parachute2"); + boolean canopy = state.kind == ParachuteEntity.Kind.DROP || state.open; + return mesh != null ? mesh.part(canopy ? "$parachute" : "$seat") : List.of(); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java new file mode 100644 index 00000000..d6eefbe0 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -0,0 +1,554 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +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.jnhprojects.mcheli.model.PartTextures; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.resources.Identifier; +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 SubmitNodeCollector collector; + private final Identifier 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, SubmitNodeCollector collector, + Identifier texture, int light, int overlay, int glassColor) { + this.def = def; + this.mesh = mesh; + this.state = state; + this.pose = pose; + this.collector = collector; + 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 = RenderTypes.entityCutoutCull(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 = RenderTypes.entityTranslucentEmissive(texture); + this.light = light; + this.overlay = overlay; + this.glassColor = glassColor; + } + + void submitAll() { + switch (def) { + case PlaneDefinition plane -> { + nozzles(plane); + wings(plane); + propellers(plane); + hull(); + } + case HelicopterDefinition heli -> { + hull(); + rotors(heli); + } + case VehicleDefinition vehicle -> { + hull(); + for (VehiclePart part : vehicle.parts) { + vehiclePart(vehicle, part); + } + } + default -> hull(); + } + weapons(); + rotatingParts(); + movingParts(def.hatches, state.hatch); + trackRollers(); + crawlerTracks(); + steeringWheels(); + wheels(); + throttles(); + cameras(); + landingGear(); + weaponBays(); + movingParts(def.canopies, state.canopy); + } + + /** + * The body and, as a {@code $} object of its own so it can have its own {@code PartTexture}, + * the cockpit. A model without {@code $body} draws as one piece, cockpit included, and no original + * model has a {@code $cockpit}. + */ + private void hull() { + submit(mesh.body()); + if (mesh.contains("$body")) { + submit(mesh.part("$cockpit")); + } + } + + // Kind specific parts + + private void rotors(HelicopterDefinition heli) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); + 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); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : 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; + } + if (def.partTextures.isEmpty()) { + submit(groups, texture, opaqueType, glassType); + return; + } + for (PartTextures.Bucket bucket : GlassSplit.INSTANCE.buckets(groups, def.partTextures)) { + if (bucket.texture() == null) { + submit(bucket.groups(), texture, opaqueType, glassType); + } else { + Identifier own = RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, + "textures/" + def.folder() + "/" + bucket.texture() + ".png")); + submit(bucket.groups(), own, RenderTypes.entityCutoutCull(own), RenderTypes.entityTranslucentEmissive(own)); + } + } + } + + private void submit(List groups, Identifier texture, RenderType opaqueType, RenderType glassType) { + GlassSplit.Split split = GlassSplit.INSTANCE.split(groups, texture); + if (!split.opaque().isEmpty()) { + collector.submitCustomGeometry(pose, opaqueType, (p, buffer) -> MeshRenderer.emit(split.opaque(), p, buffer, light, overlay)); + } + if (!split.glass().isEmpty()) { + // Culling glass relies on the camera sitting at the origin, which is not so in a GUI. + collector.submitCustomGeometry(pose, glassType, state.inGui + ? (p, buffer) -> MeshRenderer.emit(split.glass(), p, buffer, light, overlay, glassColor) + : (p, buffer) -> MeshRenderer.emitFrontFaces(split.glass(), p, buffer, light, overlay, glassColor)); + } + } + + 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-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java new file mode 100644 index 00000000..128708b3 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -0,0 +1,181 @@ +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.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.Util; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import java.util.function.Function; + +/** + * 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 { + /** Vanilla on 26.2 only offers this one drawn into the item target. */ + private static final Function TRANSLUCENT_CULL = Util.memoize(texture -> RenderType.create( + MCHeli.MOD_ID + "_translucent_cull", RenderSetup.builder(RenderPipelines.ENTITY_TRANSLUCENT_CULL) + .withTexture("Sampler0", texture) + .useLightmap() + .useOverlay() + .affectsCrumbling() + .sortOnUpload() + .setOutline(RenderSetup.OutlineProperty.AFFECTS_OUTLINE) + .createRenderSetup())); + 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 + } + + public static class State extends EntityRenderState { + 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; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ProjectileEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.model = entity.model(); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + state.visible = !entity.isSpent() && (!(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 || entity.isSpent() ? 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 submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.visible ? MeshCache.INSTANCE.find("bullets", state.model) : null; + if (mesh != null) { + Identifier texture = Identifier.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)); + int color = state.color; + collector.submitCustomGeometry(pose, TRANSLUCENT_CULL.apply(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, FULL_BRIGHT, OverlayTexture.NO_OVERLAY, color)); + pose.popPose(); + } + if (state.visible) { + submitGlow(state, pose, collector, camera); + } + super.submit(state, pose, collector, camera); + } + + private static void submitGlow(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + 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 toCamera = new Vec3(state.x - camera.pos.x, state.y - camera.pos.y, state.z - camera.pos.z); + Glow.streak(pose, collector, toCamera, back.scale(Math.min(speed, MAX_TRACER)), 0.08F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, 0.35F, state.glowColor, state.glow); + } + case MOTOR -> { + Vec3 nozzle = back.scale(MOTOR_OFFSET); + Glow.halo(pose, collector, camera, nozzle, 1.1F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, nozzle, 0.4F, GlowParticleOptions.FLASH, state.glow); + } + default -> { + } + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java new file mode 100644 index 00000000..9ddb1690 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -0,0 +1,49 @@ +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.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 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(LivingEntityRenderState renderState, PoseStack pose, float partialTicks) { + // Render states carry no entity reference; players are the ones that keep their id. + if (!(renderState instanceof AvatarRenderState state)) { + return; + } + ClientLevel level = Minecraft.getInstance().level; + Entity rider = level != null ? level.getEntity(state.id) : null; + if (rider == null || !(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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 00000000..a693052b --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,55 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + String name = ""; + float yaw; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ThrowableEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.name = entity.definitionName(); + state.yaw = entity.getYRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", state.name); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/throwable/" + state.name + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 00000000..e3f7f9c7 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + public static class State extends EntityRenderState { + float yaw; + int kind; + boolean lit; + float cover; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(UavStationEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.cover = entity.cover(partialTicks); + state.lit = state.kind == UavStationEntity.PORTABLE ? state.cover > 0.95F : entity.isLinked(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + int index = state.kind == UavStationEntity.PORTABLE ? 1 : 0; + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[index] + (state.lit ? "_on" : "") + ".png"); + pose.pushPose(); + pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); + int light = state.lightCoords; + if (index == 0) { + draw(pose, collector, texture, mesh.groups(), light); + } else { + draw(pose, collector, texture, mesh.part("$body"), light); + folding(pose, collector, texture, mesh.part("$cover"), state.cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, collector, texture, mesh.part("$laptop_cover"), state.cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, collector, texture, mesh.part("$display"), state.cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List part, + float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.mulPose(Axis.XN.rotationDegrees(angle)); + pose.translate(0.0F, -y, -z); + draw(pose, collector, texture, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List groups, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 00000000..ec38e26d --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,335 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.workshop.WorkshopHomeScreen; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.recipebook.RecipeCollection; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedItemContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.RecipeDisplay; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the player's recipe book, which holds every MCHeli recipe from login. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 400, 240); + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.workshop"), b -> openWorkshop()) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.drafting_table.workshop_hint"))) + .bounds(leftPos + 334, topPos + 210, 56, 20).build()); + selectList(list); + } + + /** The workshop is a screen of its own and needs no block, so the table's menu closes first. */ + private void openWorkshop() { + minecraft.player.closeContainer(); + minecraft.gui.setScreen(new WorkshopHomeScreen()); + } + + /** Every MCHeli recipe the recipe book holds, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + for (RecipeCollection collection : minecraft.player.getRecipeBook().getCollections()) { + for (RecipeDisplayEntry entry : collection.getRecipes()) { + ItemStack result = entry.display().result().resolveForFirstStack(context); + if (!result.isEmpty() && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(entry); + } + } + } + for (List entries : lists) { + entries.sort(Comparator.comparing(e -> e.display().result().resolveForFirstStack(context).getHoverName().getString())); + } + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeDisplayEntry current() { + List entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeDisplayEntry entry = current(); + return entry != null ? entry.display().result().resolveForFirstStack(SlotDisplayContext.fromLevel(minecraft.level)) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static Identifier pageTexture(String name, int page) { + return Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeDisplayEntry entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedItemContents contents = new StackedItemContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return entry.canCraft(contents); + } + + private void create() { + RecipeDisplayEntry entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(RenderPipelines.GUI_TEXTURED, pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, 0.0F, 0.0F, + PAGE_WIDTH / 2, PAGE_HEIGHT / 2, PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeDisplayEntry entry = current(); + if (entry == null) { + graphics.text(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.text(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.text(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.display(), mouseX, mouseY); + graphics.item(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphicsExtractor graphics, RecipeDisplay display, int mouseX, int mouseY) { + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + List slots; + int width; + switch (display) { + case ShapedCraftingRecipeDisplay shaped -> { + slots = shaped.ingredients(); + width = shaped.width(); + } + case ShapelessCraftingRecipeDisplay shapeless -> { + slots = shapeless.ingredients(); + width = 3; + } + default -> { + return; + } + } + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + List options = slots.get(i).resolveForStacks(context); + if (options.isEmpty()) { + continue; + } + ItemStack stack = options.get(tick % options.size()); + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.item(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.setTooltipForNextFrame(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphicsExtractor graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.text(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 00000000..8578f949 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,135 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.TeamColor; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamColor.RED.ordinal(); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamColor.VALUES.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + TeamColor teamColor = TeamColor.VALUES.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getSerializedName()).withColor(teamColor.textColor().getValue()))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.text(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphicsExtractor graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.text(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.text(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 00000000..0a9c3da6 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 176, 166); + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.text(font, label, 8, 6, color, false); + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.text(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java new file mode 100644 index 00000000..a320b923 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/screen/VehicleScreen.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.weapon.RoundItem; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.weapon.LegacyItems; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGui}: fuel and parachute slots on the left, the weapon + * panel on the right with its reload cost and button, and a button into the cargo chest for + * vehicles that have one. The panel is drawn from the chest texture so it looks like vanilla. + */ +public class VehicleScreen extends AbstractContainerScreen { + private static final Identifier CHEST = Identifier.withDefaultNamespace("textures/gui/container/generic_54.png"); + private static final Identifier SLOT = Identifier.withDefaultNamespace("container/slot"); + /** Height of the part above the player inventory. */ + private static final int TOP = 88; + private static final int PANEL_X = 72; + private static final int PANEL_WIDTH = 96; + private static final int LABEL = 0xFF404040; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF5555; + private static final int GREEN = 0xFF55FF55; + + private int weapon; + private boolean cargoButton; + private @Nullable Button reload; + private @Nullable Button previous; + private @Nullable Button next; + + public VehicleScreen(VehicleMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 176, TOP + 96); + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + previous = addRenderableWidget(Button.builder(Component.literal("<"), b -> step(-1)) + .bounds(leftPos + PANEL_X, topPos + 68, 16, 16).build()); + reload = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.reload"), b -> press(weapon)) + .bounds(leftPos + PANEL_X + 18, topPos + 68, PANEL_WIDTH - 36, 16).build()); + next = addRenderableWidget(Button.builder(Component.literal(">"), b -> step(1)) + .bounds(leftPos + PANEL_X + PANEL_WIDTH - 16, topPos + 68, 16, 16).build()); + AircraftEntity vehicle = menu.vehicle(); + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + cargoButton = def != null && def.cargoRows() > 0; + if (cargoButton) { + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.vehicle.cargo"), b -> press(VehicleMenu.BUTTON_CARGO)) + .bounds(leftPos + imageWidth - 52, topPos + 3, 46, 13).build()); + } + updateButtons(); + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + private int weaponCount() { + AircraftEntity vehicle = menu.vehicle(); + VehicleWeapons weapons = vehicle != null ? vehicle.weapons() : null; + return weapons != null ? Math.min(weapons.sets().size(), VehicleMenu.MAX_WEAPONS) : 0; + } + + private void step(int direction) { + int count = weaponCount(); + if (count > 0) { + weapon = Math.floorMod(weapon + direction, count); + updateButtons(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + private void updateButtons() { + if (reload == null || previous == null || next == null) { + return; + } + int count = weaponCount(); + reload.visible = count > 0; + previous.visible = count > 1; + next.visible = count > 1; + reload.active = menu.canReload(weapon); + AircraftEntity vehicle = menu.vehicle(); + boolean grounded = vehicle != null && vehicle.canSupply(); + reload.setTooltip(grounded ? null : Tooltip.create(Component.translatable("gui.mcheli.vehicle.reload_hint"))); + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + int x = leftPos; + int y = topPos; + // The chest's title bar, its plain middle stretched down to the inventory, then the inventory. + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y, 0.0F, 0.0F, imageWidth, 17, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y + 17, 0.0F, 12.0F, imageWidth, TOP - 17, imageWidth, 1, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, CHEST, x, y + TOP, 0.0F, 126.0F, imageWidth, 96, 256, 256); + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x + VehicleMenu.FUEL_X + i * 18 - 1, y + VehicleMenu.FUEL_Y - 1, 18, 18); + } + for (int i = 0; i < menu.parachuteSlots(); i++) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x + VehicleMenu.PARACHUTE_X + i * 18 - 1, y + VehicleMenu.PARACHUTE_Y - 1, 18, 18); + } + graphics.fill(x + PANEL_X - 2, y + 17, x + PANEL_X + PANEL_WIDTH + 2, y + 66, 0xFF373737); + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + AircraftEntity vehicle = menu.vehicle(); + // Long names only give way to the Cargo button, where there is one. + int titleWidth = cargoButton ? imageWidth - 64 : imageWidth - 16; + graphics.text(font, font.plainSubstrByWidth(title.getString(), titleWidth), titleLabelX, titleLabelY, LABEL, false); + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, LABEL, false); + if (vehicle == null) { + return; + } + if (vehicle.maxFuel() > 0) { + // A percentage and a gauge: the exact units would run into the weapon panel. + int percent = Math.round(vehicle.fuelRatio() * 100.0F); + graphics.text(font, Component.translatable("gui.mcheli.vehicle.fuel", percent), VehicleMenu.FUEL_X, VehicleMenu.FUEL_Y - 11, LABEL, false); + int gaugeY = VehicleMenu.FUEL_Y + 18; + int width = VehicleInventory.FUEL_SLOTS * 18 - 2; + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width, gaugeY + 3, 0xFF373737); + graphics.fill(VehicleMenu.FUEL_X, gaugeY, VehicleMenu.FUEL_X + width * percent / 100, gaugeY + 3, percent <= 10 ? RED : GREEN); + } + if (menu.parachuteSlots() > 0) { + graphics.text(font, Component.translatable("gui.mcheli.vehicle.parachute"), VehicleMenu.PARACHUTE_X, VehicleMenu.PARACHUTE_Y - 11, LABEL, false); + } + weaponPanel(graphics, vehicle); + } + + /** Name, rounds carried against the maximum, and what one reload costs. */ + private void weaponPanel(GuiGraphicsExtractor graphics, AircraftEntity vehicle) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null && weaponCount() > 0 ? weapons.set(Math.min(weapon, weaponCount() - 1)) : null; + if (set == null) { + graphics.text(font, Component.translatable("gui.mcheli.vehicle.no_weapons"), PANEL_X, 20, WHITE, false); + return; + } + graphics.text(font, font.plainSubstrByWidth(set.displayName(), PANEL_WIDTH), PANEL_X, 20, WHITE, false); + int rounds = menu.rounds(weapon); + int max = set.maxAmmo(); + int color = rounds == 0 && max > 0 ? RED : rounds >= max ? GREEN : WHITE; + graphics.text(font, rounds + " / " + max, PANEL_X, 31, color, false); + int itemX = PANEL_X; + for (RoundItem cost : set.definition().roundItems) { + Item item = LegacyItems.item(cost.item(), cost.damage()); + if (item == null) { + continue; + } + graphics.item(new ItemStack(item), itemX, 42); + graphics.text(font, String.valueOf(cost.count()), itemX + 17, 47, WHITE, false); + itemX += 32; + if (itemX > PANEL_X + PANEL_WIDTH - 16) { + break; + } + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java new file mode 100644 index 00000000..b32040f5 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.EngineSoundSource; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** + * A looping sound of one vehicle: its engine, or the rotor loop some vehicles have alongside it. + * Ported from {@code MCH_SoundUpdater}. + * + *

Volume and pitch follow the vehicle's throttle every tick, the pitch along the definition's + * curve if it has one and otherwise the way the kind of vehicle always has. For listeners outside + * the vehicle the sound is placed between them and the vehicle, so that the 16 block range of a + * normal sound stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the + * vehicle approaches or leaves, which is what the original did. + */ +final class EngineSound extends RuntimeSoundInstance { + private static final float MAX_DOPPLER = 0.2F; + + private final AircraftEntity vehicle; + private final EngineSoundSource source; + private final boolean rotor; + private float doppler; + private double lastDistance = -1.0; + + EngineSound(AircraftEntity vehicle, EngineSoundSource source, String sound, boolean rotor) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.vehicle = vehicle; + this.source = source; + this.rotor = rotor; + this.looping = true; + this.delay = 0; + update(); + } + + @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 = (rotor ? def.rotorSoundVolume : def.soundVolume) * source.engineVolume(); + PitchCurve curve = rotor ? def.rotorSoundPitchCurve : def.soundPitchCurve; + float pitch = def.soundPitch * (curve != null ? curve.at(vehicle.visibleThrottle()) : source.enginePitch()); + boolean aboard = player.getRootVehicle() == vehicle.getRootVehicle(); + if (aboard) { + x = vehicle.getX(); + 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.2/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java new file mode 100644 index 00000000..62e52284 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java @@ -0,0 +1,75 @@ +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 and rotor loops for vehicles near the player and restarts them after they fall + * silent. The sounds themselves track their vehicle until it stops or disappears. + */ +public final class EngineSoundPlayer { + /** Ticks to wait before restarting a loop, as the original did, so a stuttering engine does not spam. */ + private static final int RESTART_DELAY = 20; + + /** By {@link #key}, since a vehicle can have two loops. */ + private static final Map PLAYING = new HashMap<>(); + private static final Map COOLDOWN = new HashMap<>(); + + private EngineSoundPlayer() { + } + + 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; + } + AircraftDefinition def = vehicle.definition(); + if (def == null) { + continue; + } + double range = def.soundRange * 1.2; + if (vehicle.distanceToSqr(player) > range * range) { + continue; + } + start(mc, vehicle, source, def.soundOrDefault(), def.soundVolume, false); + start(mc, vehicle, source, def.rotorSound, def.rotorSoundVolume, true); + } + } + + private static void start(Minecraft mc, AircraftEntity vehicle, EngineSoundSource source, String sound, float volume, boolean rotor) { + long key = key(vehicle, rotor); + if (sound.isEmpty() || PLAYING.containsKey(key) || COOLDOWN.containsKey(key) || volume * source.engineVolume() <= 0.01F) { + return; + } + EngineSound loop = new EngineSound(vehicle, source, sound, rotor); + mc.getSoundManager().play(loop); + PLAYING.put(key, loop); + COOLDOWN.put(key, RESTART_DELAY); + } + + private static long key(AircraftEntity vehicle, boolean rotor) { + return (long) vehicle.getId() << 1 | (rotor ? 1L : 0L); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java new file mode 100644 index 00000000..31825a4f --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/OneShotSound.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.ModSounds; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; + +/** A sound from a content pack played once at a point, such as a gun firing. */ +public final class OneShotSound extends RuntimeSoundInstance { + public OneShotSound(String sound, double x, double y, double z, float volume, float pitch) { + super(ModSounds.get(sound), SoundSource.NEUTRAL, RandomSource.create()); + this.x = x; + this.y = y; + this.z = z; + this.volume = volume; + this.pitch = pitch; + } + + @Override + public void tick() { + // It stays where it was started and ends when the file does. + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 00000000..e42d8cbd --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +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.Identifier; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.util.Util; +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 = super.resolve(manager); + if (events != null) { + return events; + } + file = RuntimeAssets.soundFile(identifier.getPath()); + if (file == null) { + return null; + } + // Streamed, so the file is read as it plays and never cached as a buffer. + sound = new Sound(identifier, new ConstantFloat(1.0F), new ConstantFloat(1.0F), 1, Sound.Type.FILE, true, false, 16); + return new WeighedSoundEvents(identifier, null); + } + + /** + * NeoForge calls this through its sound instance extension, which it overrides without saying + * so. Fabric has no such method and calls {@link #openStream} from a mixin. Forge declares it + * too but never calls it on this version, see {@code PackSoundBuffers} in its module. + */ + 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(path, looping); + } + return streamFile(source, looping); + } + + /** Streams an OGG file from disk, read as it plays and never cached as a buffer. */ + public static CompletableFuture streamFile(Path source, boolean 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-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java new file mode 100644 index 00000000..a239b5fc --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopClient.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSubmitPacket; +import com.jnhprojects.mcheli.network.TestFlightPacket; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; + +/** + * What the workshop does beyond its screens: takes the player to a test flight and back, and sends + * a pack to the server. The screen stays alive during a flight, so the undo history and the view are + * as they were when the pilot gets out. + */ +public final class WorkshopClient { + /** If no vehicle has come after this long, the server turned the flight down and said why. */ + private static final int SPAWN_TIMEOUT_TICKS = 100; + /** About 1.2 MB a second, so a download-sized pack does not crowd out the game's own packets. */ + private static final int SUBMIT_CHUNKS_PER_TICK = 2; + + private static @Nullable WorkshopScreen waiting; + private static boolean airborne; + private static int ticks; + private static byte @Nullable [] submitting; + private static int submitted; + + private WorkshopClient() { + } + + static void startTestFlight(WorkshopScreen screen, String vehicle) { + waiting = screen; + airborne = false; + ticks = 0; + Minecraft.getInstance().gui.setScreen(null); + ModNetwork.sendToServer(new TestFlightPacket(vehicle)); + } + + /** Starts sending a pack's zip to the server, a few chunks a tick. */ + static void submit(byte[] zip) { + submitting = zip; + submitted = 0; + } + + /** How far along the zip being sent is, from 0 to 100, or -1 when none is. */ + static int submitProgress() { + byte[] zip = submitting; + return zip != null ? (int) (submitted * 100L / zip.length) : -1; + } + + public static void tick() { + tickSubmit(); + WorkshopScreen screen = waiting; + Minecraft mc = Minecraft.getInstance(); + if (screen == null || mc.player == null) { + return; + } + boolean flying = mc.player.getVehicle() instanceof AircraftEntity; + airborne |= flying; + ticks++; + if (airborne ? !flying : ticks > SPAWN_TIMEOUT_TICKS) { + waiting = null; + // Not over another screen the player has opened in flight, such as the pause menu. + if (mc.gui.screen() == null) { + mc.gui.setScreen(screen); + } + } + } + + private static void tickSubmit() { + byte[] zip = submitting; + if (zip == null) { + return; + } + for (int i = 0; i < SUBMIT_CHUNKS_PER_TICK && submitted < zip.length; i++) { + int end = Math.min(submitted + PackSubmitPacket.CHUNK_SIZE, zip.length); + ModNetwork.sendToServer(new PackSubmitPacket(submitted, zip.length, Arrays.copyOfRange(zip, submitted, end))); + submitted = end; + } + if (submitted >= zip.length) { + submitting = null; + } + } + + public static void loggedOut() { + submitting = null; + WorkshopScreen screen = waiting; + waiting = null; + if (screen != null) { + screen.releaseAssets(); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java new file mode 100644 index 00000000..4fec9f18 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopHomeScreen.java @@ -0,0 +1,201 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.workshop.ModelImport; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopKind; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** + * Where the workshop starts: the vehicles in the content packs on the left, ready to open, and on + * the right a form to start a new one from an OBJ file, which can be dropped on the window. + */ +public class WorkshopHomeScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int FORM_WIDTH = 190; + private static final int MAX_LIST_WIDTH = 300; + private static final int LINE_HEIGHT = 10; + + private final List entries; + private int scroll; + private WorkshopKind kind = WorkshopKind.HELICOPTER; + private String vehicleText = ""; + private String packText = ""; + private String modelText = ""; + private @Nullable Component error; + private @Nullable EditBox vehicleBox; + private @Nullable EditBox packBox; + private @Nullable EditBox modelBox; + + public WorkshopHomeScreen() { + super(Component.translatable("gui.mcheli.workshop.title")); + entries = WorkshopProject.list(MCHeli.packs().folder()); + } + + @Override + protected void init() { + listRows(); + form(); + } + + private int listWidth() { + return Math.clamp(width - FORM_WIDTH - 30, 100, MAX_LIST_WIDTH); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + private void listRows() { + int rows = visibleRows(); + for (int i = scroll; i < entries.size() && i < scroll + rows; i++) { + WorkshopProject.Entry entry = entries.get(i); + String suffix = entry.zipped() ? " (zip)" : ""; + addRenderableWidget(Button.builder(Component.literal(entry.pack() + " / " + entry.vehicle() + suffix), b -> open(entry)) + .bounds(10, TOP + (i - scroll) * ROW, listWidth(), 20).build()); + } + } + + private void form() { + int x = width - FORM_WIDTH - 10; + int y = TOP; + vehicleBox = box(x, y + 10, Component.translatable("gui.mcheli.workshop.new.vehicle"), vehicleText, 64); + vehicleBox.setResponder(text -> { + // The pack follows the vehicle's name until the player gives it its own. + if (packBox != null && packBox.getValue().equals(vehicleText)) { + packBox.setValue(text); + } + vehicleText = text; + }); + packBox = box(x, y + 44, Component.translatable("gui.mcheli.workshop.new.pack"), packText, 64); + packBox.setResponder(text -> packText = text); + addRenderableWidget(Button.builder(kindName(), b -> { + kind = WorkshopKind.values()[(kind.ordinal() + 1) % WorkshopKind.values().length]; + b.setMessage(kindName()); + }).bounds(x, y + 68, FORM_WIDTH, 20).build()); + modelBox = box(x, y + 102, Component.translatable("gui.mcheli.workshop.new.model"), modelText, 1024); + modelBox.setResponder(text -> modelText = text); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.new.create"), b -> create()) + .bounds(x, y + 126, FORM_WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.home.packs"), b -> minecraft.gui.setScreen(new WorkshopPacksScreen())) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private EditBox box(int x, int y, Component name, String value, int maxLength) { + EditBox box = new EditBox(font, x, y, FORM_WIDTH, 18, name); + box.setMaxLength(maxLength); + box.setValue(value); + return addRenderableWidget(box); + } + + private Component kindName() { + return Component.translatable("gui.mcheli.workshop.new.kind", Component.translatable("gui.mcheli.workshop.kind." + kind.id())); + } + + private void open(WorkshopProject.Entry entry) { + try { + minecraft.gui.setScreen(new WorkshopScreen(WorkshopProject.open(MCHeli.packs().folder(), entry))); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + private void create() { + String vehicle = vehicleText.trim(); + AircraftDefinition existing = MCHeli.content().any(vehicle); + if (existing != null && existing.pack().equals(ContentSource.ORIGINAL)) { + // The import's models come from a resource pack, which would win over this vehicle's. + error = Component.translatable("gui.mcheli.workshop.new.name_taken", vehicle); + return; + } + try { + Path obj = Path.of(modelText.trim().replace("\"", "")); + String author = minecraft.player != null ? minecraft.player.getGameProfile().name() : ""; + WorkshopProject project = WorkshopProject.create(MCHeli.packs().folder(), packText.trim(), kind, vehicle, author, + obj, ModelImport.findTexture(obj)); + minecraft.gui.setScreen(new WorkshopScreen(project)); + } catch (InvalidPathException e) { + error = Component.translatable("gui.mcheli.workshop.new.bad_path", modelText); + } catch (WorkshopException e) { + error = Component.literal(e.getMessage()); + } + } + + /** A file dropped on the window fills in the form, its name standing in for the vehicle's. */ + @Override + public void onFilesDrop(List files) { + if (files.isEmpty() || modelBox == null || vehicleBox == null) { + return; + } + Path file = files.getFirst(); + modelBox.setValue(file.toString()); + if (vehicleText.isEmpty()) { + String name = file.getFileName().toString(); + int dot = name.lastIndexOf('.'); + vehicleBox.setValue((dot > 0 ? name.substring(0, dot) : name).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-")); + } + error = null; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int max = Math.max(entries.size() - visibleRows(), 0); + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, max); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 10, TEXT); + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.open"), 10, TOP - 12, DIM); + if (entries.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.none"), 10, TOP, DIM); + } + int x = width - FORM_WIDTH - 10; + graphics.text(font, Component.translatable("gui.mcheli.workshop.home.new"), x, TOP - 12, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.vehicle"), x, TOP, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.pack"), x, TOP + 34, DIM); + graphics.text(font, Component.translatable("gui.mcheli.workshop.new.model"), x, TOP + 92, DIM); + int y = TOP + 150; + for (var line : font.split(Component.translatable("gui.mcheli.workshop.home.drop"), FORM_WIDTH)) { + graphics.text(font, line, x, y, DIM); + y += LINE_HEIGHT; + } + if (error != null) { + y += 2; + for (var line : font.split(error, FORM_WIDTH)) { + graphics.text(font, line, x, y, ERROR); + y += LINE_HEIGHT; + } + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java new file mode 100644 index 00000000..07005448 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopPacksScreen.java @@ -0,0 +1,218 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.ClientPacks; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.workshop.PackCatalog; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The package browser: the packs in {@code mcheli/packs} and those the server in use sent, who made + * them and what is in them. A zip someone gave the player can be dropped on it to install. + * + *

This only ever reads the packs and adds to the folder. Removing one is left to the file manager + * the Open folder button opens, since a pack can be the only copy of someone's work. + */ +public class WorkshopPacksScreen extends Screen { + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + private static final int ERROR = 0xFFFF7070; + private static final int GOOD = 0xFF80E080; + private static final int ROW = 22; + private static final int TOP = 36; + private static final int LIST_WIDTH = 170; + private static final int LINE_HEIGHT = 10; + private static final int VEHICLES_SHOWN = 8; + + /** A pack in the list, and where it came from. */ + private record Row(PackCatalog.Info info, boolean fromServer) { + } + + private final List rows = new ArrayList<>(); + private final List problems = new ArrayList<>(); + private int scroll; + private int selected; + private @Nullable Component message; + private int messageColor = DIM; + + public WorkshopPacksScreen() { + super(Component.translatable("gui.mcheli.workshop.packs.title")); + read(); + } + + private void read() { + rows.clear(); + problems.clear(); + for (ContentPack pack : ClientPacks.INSTANCE.serverPacks()) { + try { + rows.add(new Row(PackCatalog.info(pack), true)); + } catch (IOException e) { + problems.add(new PackProblem(pack.id(), "could not be read: " + e)); + } + } + PackCatalog.Listing own = PackCatalog.read(MCHeli.packs().folder()); + for (PackCatalog.Info info : own.packs()) { + // A pack the player has that the server also uses is listed once, as the server's. + if (rows.stream().noneMatch(row -> row.info().hash().equals(info.hash()))) { + rows.add(new Row(info, false)); + } + } + problems.addAll(own.problems()); + selected = Math.clamp(selected, 0, Math.max(rows.size() - 1, 0)); + } + + private int visibleRows() { + return Math.max((height - TOP - 34) / ROW, 1); + } + + @Override + protected void init() { + scroll = Math.clamp(scroll, 0, Math.max(rows.size() - visibleRows(), 0)); + for (int i = scroll; i < rows.size() && i < scroll + visibleRows(); i++) { + int index = i; + Button row = addRenderableWidget(Button.builder(Component.literal(rows.get(i).info().manifest().name()), b -> { + selected = index; + rebuildWidgets(); + }).bounds(10, TOP + (i - scroll) * ROW, LIST_WIDTH, 20).build()); + row.active = i != selected; + } + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.workshop.packs.open_folder"), b -> openFolder()) + .bounds(width / 2 - 104, height - 26, 100, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 26, 100, 20).build()); + } + + private void openFolder() { + Path folder = MCHeli.packs().folder(); + try { + Files.createDirectories(folder); + Util.getPlatform().openPath(folder); + } catch (IOException e) { + say(Component.literal(e.toString()), ERROR); + } + } + + private void say(Component text, int color) { + message = text; + messageColor = color; + } + + @Override + public void onFilesDrop(List files) { + for (Path file : files) { + if (!file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) { + say(Component.translatable("gui.mcheli.workshop.packs.not_zip", file.getFileName().toString()), ERROR); + continue; + } + try { + PackManifest installed = PackCatalog.install(file, MCHeli.packs().folder()); + say(Component.translatable("gui.mcheli.workshop.packs.installed", installed.name()), GOOD); + read(); + rebuildWidgets(); + } catch (WorkshopException e) { + say(Component.literal(e.getMessage()), ERROR); + } + } + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int next = Math.clamp(scroll - (int) Math.signum(scrollY), 0, Math.max(rows.size() - visibleRows(), 0)); + if (next != scroll) { + scroll = next; + rebuildWidgets(); + } + return true; + } + + @Override + public void onClose() { + minecraft.gui.setScreen(new WorkshopHomeScreen()); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 10, TEXT); + if (rows.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.workshop.packs.none"), 10, TOP, DIM); + } + int x = 10 + LIST_WIDTH + 10; + int w = width - x - 10; + int y = TOP; + if (selected < rows.size()) { + y = details(graphics, rows.get(selected), x, y, w) + LINE_HEIGHT; + } + for (PackProblem problem : problems) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.problem", problem.pack(), problem.message()), x, y, w, ERROR); + } + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.drop"), x, y + 4, w, DIM); + if (message != null) { + wrapped(graphics, message, x, y + 4, w, messageColor); + } + } + + private int details(GuiGraphicsExtractor graphics, Row row, int x, int y, int w) { + PackCatalog.Info info = row.info(); + PackManifest m = info.manifest(); + y = wrapped(graphics, Component.literal(m.name() + " " + m.version()), x, y, w, TEXT); + if (!m.author().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.author", m.author()), x, y, w, TEXT); + } + if (!m.description().isEmpty()) { + y = wrapped(graphics, Component.literal(m.description()), x, y + 2, w, DIM); + } + y += 4; + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.id", m.id()), x, y, w, DIM); + // A small pack is still something, and 0.0 MB reads as an empty one. + String megabytes = "%.1f".formatted(Math.max(info.size() / 1048576.0, 0.1)); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.size", megabytes, ContentPack.shortHash(info.hash())), x, y, w, DIM); + Component source = row.fromServer() ? Component.translatable("gui.mcheli.workshop.packs.from_server") + : Component.literal(info.source().getFileName().toString() + (info.zipped() ? "" : "/")); + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.source", source), x, y, w, DIM); + y += 4; + if (!info.vehicles().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.vehicles", info.vehicles().size(), shortList(info.vehicles())), x, y, w, TEXT); + } + if (!info.weapons().isEmpty()) { + y = wrapped(graphics, Component.translatable("gui.mcheli.workshop.packs.weapons", info.weapons().size(), shortList(info.weapons())), x, y, w, TEXT); + } + return y; + } + + private static String shortList(List names) { + if (names.size() <= VEHICLES_SHOWN) { + return String.join(", ", names); + } + return String.join(", ", names.subList(0, VEHICLES_SHOWN)) + ", ..."; + } + + private int wrapped(GuiGraphicsExtractor graphics, Component text, int x, int y, int w, int color) { + for (FormattedCharSequence line : font.split(text, w)) { + graphics.text(font, line, x, y, color); + y += LINE_HEIGHT; + } + return y; + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java new file mode 100644 index 00000000..fbe83884 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/client/workshop/WorkshopScreen.java @@ -0,0 +1,1646 @@ +package com.jnhprojects.mcheli.client.workshop; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; +import com.jnhprojects.mcheli.client.render.AircraftRenderState; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.client.sound.OneShotSound; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.PitchCurve; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.pack.PackManifest; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.workshop.AssetImport; +import com.jnhprojects.mcheli.workshop.EditHistory; +import com.jnhprojects.mcheli.workshop.PartField; +import com.jnhprojects.mcheli.workshop.PartType; +import com.jnhprojects.mcheli.workshop.TuningKey; +import com.jnhprojects.mcheli.workshop.WorkshopCamera; +import com.jnhprojects.mcheli.workshop.WorkshopException; +import com.jnhprojects.mcheli.workshop.WorkshopPart; +import com.jnhprojects.mcheli.workshop.WorkshopProject; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.FormattedCharSequence; +import org.joml.Matrix3x2fStack; +import org.joml.Vector3f; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * The workshop's editor. The vehicle turns in the middle, drawn by the game's own vehicle renderer + * through a GUI entity, so what shows is what will fly. The parts list is on the left, the values + * of what is selected on the right, and gizmos for every part lie over the model. + * + *

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

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

Movement is simulated wherever {@link #isLocalInstanceAuthoritative()} 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 VehicleEntity { + 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); + private static final EntityDataAccessor DATA_FUEL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + /** The UAV station a drone is flown from, by entity id, or 0. */ + private static final EntityDataAccessor DATA_STATION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_REPELLING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SEARCH_LIGHT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + /** Marks a player flying a drone from a station, so a stale hide can be undone at login. */ + public static final String REMOTE_PILOT_TAG = "mcheli_remote_pilot"; + + // 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; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + 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<>(); + /** The station a drone is flown from, and where its pilot goes back to. Server only. */ + private @Nullable UUID station; + private @Nullable Vec3 stationReturn; + /** Remote pilots leaving this tick, by entity id, with where they come back down. */ + private final Map remoteReturns = new HashMap<>(); + /** Where a workshop test flight started, or {@code null} for an ordinary vehicle. Server only. */ + private @Nullable Vec3 testFlightStart; + private boolean testFlightOver; + /** How far the ropes hang below their hooks, as a negative number; both sides let them out. */ + private float ropeLength; + private int repellingTicks; + private int lastRope; + /** Passengers are leaving one by one under parachutes. Server only. */ + private boolean droppingTroops; + + 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; + /** The fuel load from which an aircraft counts as heavy: the H8K flying boat and up. */ + private static final int HEAVY_FUEL = 3000; + /** How long a wreck stays, burning, before it disappears: 30 seconds. */ + private static final int WRECK_TICKS = 600; + /** 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; + /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ + private int wreckTicks; + /** Ticks since this aircraft was shot down, for the crash spin. Counted on both sides. */ + private int spinTicks; + 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; + + /** Fuel burnt but not yet taken from the tank, which only holds whole units. Server only. */ + private double fuelBurnt; + /** Ticks a fuel truck nearby keeps this vehicle from burning fuel. Server only. */ + private int fuelSuppliedCount; + /** One bit per seat whose occupant has night vision on. Server only, not saved. */ + private int nightVisionSeats; + /** Fuel and parachute slots and the cargo chest. Only the server's copy holds anything. */ + private final VehicleInventory inventory = new VehicleInventory(this); + + // 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; + /** + * How far a hull reaches past its main box, at most, when looking for neighbours: other + * vehicles are found by their main box, and the longest hulls stretch about this far from it. + */ + private static final double HULL_REACH = 24.0; + /** 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) { + super.defineSynchedData(builder); + 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); + builder.define(DATA_FUEL, 0); + builder.define(DATA_STATION, 0); + builder.define(DATA_REPELLING, false); + builder.define(DATA_SEARCH_LIGHT, 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); + entityData.set(DATA_FUEL, definition.maxFuel); + 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); + } + + /** + * {@code repair}: adds hit points up to the maximum. A wreck stays a wreck. Server only. + * + * @return whether anything was repaired + */ + public boolean repair(int amount) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || hp() >= def.maxHp) { + return false; + } + entityData.set(DATA_HP, Math.min(hp() + amount, def.maxHp)); + return true; + } + + /** + * {@code switchNextTextureName}: the next paint scheme the definition lists. Server only. + * + * @return false when the vehicle has only one + */ + public boolean nextTexture() { + AircraftDefinition def = definition(); + if (def == null || def.textures().size() < 2) { + return false; + } + List textures = def.textures(); + int next = (textures.indexOf(texture()) + 1) % textures.size(); + entityData.set(DATA_TEXTURE, textures.get(next)); + return true; + } + + // Fuel + + public int fuel() { + return entityData.get(DATA_FUEL); + } + + public int maxFuel() { + AircraftDefinition def = definition(); + return def != null ? def.maxFuel : 0; + } + + /** {@code getFuelP}: how full the tank is, 0 to 1. Vehicles without a tank read as full. */ + public float fuelRatio() { + int max = maxFuel(); + return max <= 0 ? 1.0F : (float) fuel() / max; + } + + /** {@code canUseFuel}: a vehicle without a tank never runs dry. */ + public boolean hasFuel() { + return maxFuel() <= 0 || fuel() > 1; + } + + /** Sets the tank outright; a drone leaves its station with 5%. Server only. */ + public void setFuelLevel(int fuel) { + entityData.set(DATA_FUEL, Math.clamp(fuel, 0, Math.max(maxFuel(), 0))); + } + + /** + * Pours up to {@code amount} into the tank. Server only. + * + * @return how much went in + */ + public int refuel(int amount) { + int added = Math.clamp(maxFuel() - fuel(), 0, Math.max(amount, 0)); + if (added > 0) { + entityData.set(DATA_FUEL, fuel() + added); + } + return added; + } + + // Night vision + + /** A crew member switched night vision on or off. Server only. */ + public void setNightVision(Player player, boolean on) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (seat < 0 || seat >= 31) { + return; + } + if (on && def != null && def.nightVision && !isDestroyed()) { + nightVisionSeats |= 1 << seat; + } else if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(player); + } + } + + /** Keeps night vision on for everyone in the crew who switched it on, see {@link NightVision}. */ + private void tickNightVision() { + if (nightVisionSeats == 0) { + return; + } + if (isDestroyed()) { + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (crew != null) { + NightVision.end(crew); + } + } + nightVisionSeats = 0; + return; + } + for (int seat = 0; seat < seatIds.length; seat++) { + Entity crew = occupant(seat); + if (hasNightVision(seat) && crew != null) { + NightVision.topUp(crew); + } + } + } + + /** Only the first 31 seats can switch it on; racks and the like come after them anyway. */ + private boolean hasNightVision(int seat) { + return seat >= 0 && seat < 31 && (nightVisionSeats & 1 << seat) != 0; + } + + /** + * {@code updateFuel}: once a second the engine burns its {@code FuelConsumption}, scaled by the + * throttle and topping out at 70% throttle. Nothing burns next to a fuel truck or with the + * server's infinite fuel on. + */ + private void burnFuel(AircraftDefinition def) { + if (def.maxFuel <= 0 || isDestroyed()) { + return; + } + if (fuelSuppliedCount > 0) { + fuelSuppliedCount--; + return; + } + if (ticksAlive % 20 != 0 || fuel() <= 1 || throttle <= 0.0 || ServerConfig.infiniteFuel()) { + return; + } + fuelBurnt += Math.min(throttle * 1.4, 1.0) * def.fuelConsumption; + if (fuelBurnt > 1.0) { + int whole = (int) fuelBurnt; + fuelBurnt -= whole; + entityData.set(DATA_FUEL, Math.max(fuel() - whole, 0)); + } + } + + /** + * {@code supplyFuel}: a fuel truck ({@code FuelSupplyRange}) tops up every vehicle within range, + * 30 units every half second. While it is on the ground it only reaches vehicles that are on + * the ground too. + */ + private void supplyFuel(AircraftDefinition def) { + float range = def.fuelSupplyRange; + if (range <= 0.0F || ticksAlive % 10 != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + if (other == this) { + continue; + } + if (!onGround() || other.onGround()) { + other.refuel(30); + } + other.fuelSuppliedCount = 40; + } + } + + // Inventory and supply + + public VehicleInventory inventory() { + return inventory; + } + + /** + * {@code canSupply}: fuel cans and resupply only work while the vehicle stands on something, + * within three blocks; a vehicle that cannot float must not be in the water either. + */ + public boolean canSupply() { + AircraftDefinition def = definition(); + return def != null && hasBlockBelow(1, 3, false) && (def.floats || !isInWater()); + } + + /** {@code openGui}: shows a crew member the vehicle screen. Server only. */ + public void openScreen(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || isDestroyed() || player.getVehicle() != this) { + return; + } + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new VehicleMenu(id, playerInventory, this), displayName(def))); + } + + /** + * {@code openInventory}: shows the cargo chest of a vehicle with an {@code InventorySize}. + * Server only. + * + * @return false when the vehicle has none + */ + public boolean openCargo(ServerPlayer player) { + AircraftDefinition def = definition(); + if (def == null || def.cargoRows() <= 0 || isDestroyed() || !inventory.stillValid(player)) { + return false; + } + int rows = Math.min(def.cargoRows(), 6); + MenuType type = switch (rows) { + case 1 -> MenuType.GENERIC_9x1; + case 2 -> MenuType.GENERIC_9x2; + case 3 -> MenuType.GENERIC_9x3; + case 4 -> MenuType.GENERIC_9x4; + case 5 -> MenuType.GENERIC_9x5; + default -> MenuType.GENERIC_9x6; + }; + player.openMenu(new SimpleMenuProvider((id, playerInventory, p) -> new ChestMenu(type, id, playerInventory, inventory.cargo(), rows), + displayName(def))); + return true; + } + + /** The vehicle's name as its item shows it. */ + private static Component displayName(AircraftDefinition def) { + return Component.translatableWithFallback("item.mcheli." + def.name(), def.displayName); + } + + /** + * {@code supplyAmmoToOtherAircraft}: an ammunition truck ({@code AmmoSupplyRange}) tops up the + * weapons of every vehicle within range that stands on something, a tenth of each weapon's + * maximum every two seconds. + */ + private void supplyAmmo(AircraftDefinition def) { + float range = def.ammoSupplyRange; + if (range <= 0.0F || ticksAlive % SupplyRules.AMMO_TRUCK_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + VehicleWeapons theirs = other.weapons(); + if (other == this || theirs == null || other.isDestroyed() || !other.canSupply()) { + continue; + } + for (WeaponSet set : theirs.sets()) { + int rounds = SupplyRules.roundsFromTruck(set.ammo() + set.restAmmo(), set.maxAmmo()); + if (rounds > 0) { + set.setRestAmmo(set.restAmmo() + rounds); + if (set.ammo() <= 0) { + set.reloadMagazine(); + } + } + } + } + } + + /** {@code repairOtherAircraft}: a repair vehicle ({@code RepairOtherVehicles}) mends those in range. */ + private void repairOthers(AircraftDefinition def) { + float range = def.repairOtherVehiclesRange; + if (range <= 0.0F || ticksAlive % SupplyRules.REPAIR_INTERVAL != 0 || isDestroyed()) { + return; + } + for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(range))) { + AircraftDefinition theirs = other.definition(); + if (other != this && theirs != null) { + other.repair(SupplyRules.repair(other.hp(), theirs.maxHp, def.repairOtherVehiclesValue)); + } + } + } + + /** 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 isLocalInstanceAuthoritative() && !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 + + /** + * Makes this a workshop test flight: it takes no damage, is not saved with the world, and goes + * away once its pilot gets out, putting them back where they started. + */ + public void startTestFlight(Vec3 start) { + testFlightStart = start; + } + + @Override + public boolean shouldBeSaved() { + return testFlightStart == null && super.shouldBeSaved(); + } + + @Override + public void tick() { + if (testFlightOver) { + discard(); + return; + } + rollO = roll(); + if (getHurtTime() > 0) { + setHurtTime(getHurtTime() - 1); + } + super.tick(); + interpolation.interpolate(); + ticksAlive++; + lastSeats.clear(); + remoteReturns.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 (!level().isClientSide()) { + supplyFuel(def); + supplyAmmo(def); + repairOthers(def); + burnFuel(def); + if (def.maxFuel > 0 && fuel() < def.maxFuel && !isDestroyed()) { + inventory.pourFuelCans(ticksAlive); + } + tickNightVision(); + tickStation(); + } + tickRopes(def); + + if (isSimulatingHere()) { + if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { + input.clear(); + } + if (isRepelling()) { + // {@code MCH_AircraftClientTickHandler}: the pilot holds a hover while the ropes are out. + input.throttleUp = false; + input.throttleDown = false; + input.left = false; + input.right = false; + } + if (!hasFuel()) { + // An empty tank: the throttle runs down by itself, as with the pilot's hand off it. + input.throttleUp = false; + } + 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 (!isLocalInstanceAuthoritative()) { + 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); + VehicleParticles.tick(this, 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) { + hurtServer((ServerLevel) level(), 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)) { + hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), crashDamage(previous.horizontalDistance())); + } + if (speed > VEHICLE_CRASH_SPEED && collisionCooldown == 0) { + // A blocked move can leave the hulls up to a tick of travel apart; when the vehicle + // stopped short this tick, look that far for what stopped it. + boolean stopped = velocity.length() < previous.length() * 0.5; + double reach = stopped ? 0.5 + previous.length() : 0.5; + for (AircraftEntity other : solidNeighbours(hullBounds().inflate(reach))) { + if (!hullsTouch(other, reach)) { + continue; + } + 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); + hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), damage); + other.hurtServer((ServerLevel) level(), 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; + hurtServer((ServerLevel) level(), 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) { + hurtServer((ServerLevel) level(), 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() || current == 0 && station != null) { + // A remote pilot stays at the controls; they are not really aboard. + 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); + } + + // UAV stations + + /** Whether a player at a station could take this drone's controls now. */ + public boolean canTakeRemotePilot() { + AircraftDefinition def = definition(); + return def != null && def.uav && !def.targetDrone && !isDestroyed() && isSeatFree(0); + } + + /** + * Seats {@code player}, who sits at {@code from}, at this drone's controls. The pilot is hidden + * and takes no harm while flying, since in the original they never left the station. Server only. + */ + public boolean takeRemotePilot(ServerPlayer player, UavStationEntity from) { + if (!canTakeRemotePilot()) { + return false; + } + pendingSeat = 0; + boolean seated = player.startRiding(this, true, true); + pendingSeat = EMPTY; + if (!seated) { + return false; + } + station = from.getUUID(); + stationReturn = from.returnPosition(); + entityData.set(DATA_STATION, from.getId()); + player.addTag(REMOTE_PILOT_TAG); + player.setInvisible(true); + player.setInvulnerable(true); + return true; + } + + /** Sends the remote pilot back to the station, if one is flying. Server only. */ + public void releaseRemotePilot() { + Entity pilot = occupant(0); + if (station != null && pilot != null) { + pilot.stopRiding(); + } + } + + /** The station this drone is flown from, as the client sees it. */ + public @Nullable UavStationEntity stationEntity() { + return level().getEntity(entityData.get(DATA_STATION)) instanceof UavStationEntity s ? s : null; + } + + /** Flat distance to the station this drone is flown from, or -1 without one. */ + public double stationDistance() { + UavStationEntity from = stationEntity(); + if (from == null) { + return -1.0; + } + double dx = getX() - from.getX(); + double dz = getZ() - from.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } + + /** Whether {@code entity} flies this drone from a station rather than sitting in it. */ + public boolean isRemotePilot(Entity entity) { + return (station != null || entityData.get(DATA_STATION) != 0) && occupant(0) == entity; + } + + private void endRemoteControl(Entity pilot) { + UavStationEntity from = level() instanceof ServerLevel server && station != null + && server.getEntity(station) instanceof UavStationEntity s ? s : null; + Vec3 home = from != null ? from.returnPosition() : stationReturn; + if (home != null) { + remoteReturns.put(pilot.getId(), home); + } + clearRemotePilot(pilot); + if (from != null) { + from.setLinked(false); + } + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + } + + /** Undoes what {@link #takeRemotePilot} did to the player. */ + public static void clearRemotePilot(Entity pilot) { + if (pilot.removeTag(REMOTE_PILOT_TAG)) { + pilot.setInvisible(false); + pilot.setInvulnerable(false); + } + } + + /** + * {@code updateUAV}: the link breaks when the station is gone, and flying out of its range + * loses the drone, as in the original. + */ + private void tickStation() { + if (station == null || !(level() instanceof ServerLevel server)) { + return; + } + Entity pilot = occupant(0); + if (pilot == null) { + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + return; + } + UavStationEntity from = server.getEntity(station) instanceof UavStationEntity s && s.isAlive() ? s : null; + if (from == null) { + pilot.stopRiding(); + return; + } + entityData.set(DATA_STATION, from.getId()); + if (!from.inRange(this) && !isDestroyed()) { + if (pilot instanceof Player player) { + player.sendOverlayMessage(Component.translatable("message.mcheli.uav.lost")); + } + pilot.stopRiding(); + hurtServer(server, damageSources().fellOutOfWorld(), hp() + 10.0F); + } + } + + @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); + } + // Coming back aboard after a reload, a remote pilot included. + Integer saved = savedSeats.get(passenger.getUUID()); + if (saved != null && isSeatFree(saved)) { + return true; + } + // Cargo only boards through a rack, never into a crew seat. + return !(passenger instanceof AircraftEntity || passenger instanceof ContainerEntity) && 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) { + if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + NightVision.end(passenger); + } + lastSeats.put(passenger.getId(), seat); + seatIds[seat] = EMPTY; + entityData.set(DATA_SEATS, encodeSeats()); + if (weapons != null) { + weapons.onLeft(seat); + } + } + if (seat == 0) { + input.clear(); + if (station != null) { + endRemoteControl(passenger); + } + if (testFlightStart != null) { + remoteReturns.put(passenger.getId(), testFlightStart); + testFlightOver = true; + } + } + } + + @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); + } else if (def != null && passenger instanceof ContainerEntity container) { + RackDefinition rack = def.rack(seatOf(passenger)); + container.setYRot(getYRot() + (rack != null ? rack.yaw() : 0.0F)); + container.setDeltaMovement(Vec3.ZERO); + } + } + + @Override + public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + Vec3 home = remoteReturns.remove(passenger.getId()); + if (home != null) { + return home; + } + 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); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + ItemStack held = player.getItemInHand(hand); + if (held.getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (definition() != null && (held.getItem() instanceof FuelItem || held.getItem() instanceof WrenchItem)) { + if (level().isClientSide()) { + return InteractionResult.SUCCESS; + } + return held.getItem() instanceof FuelItem ? FuelItem.pour(held, this, player) : WrenchItem.useOn(held, this, player, hand); + } + InteractionResult result = super.interact(player, hand, location); + AircraftDefinition def = definition(); + if (result == InteractionResult.PASS && player.isSecondaryUseActive() && def != null && def.cargoRows() > 0 + && !isDestroyed() && player.getVehicle() != this) { + // Sneaking opens the cargo chest from outside, as in the original. + if (player instanceof ServerPlayer server) { + openCargo(server); + } + return InteractionResult.SUCCESS; + } + if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { + return result; + } + 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(); + boardNearbyMobs(); + } + } + return InteractionResult.SUCCESS; + } + + // Racks + + /** Loads {@code cargo} into the given rack seat. Server only. */ + private boolean loadCargo(Entity cargo, int seat) { + if (!isSeatFree(seat) || cargo.isPassenger()) { + return false; + } + pendingSeat = seat; + boolean loaded = cargo.startRiding(this, true, 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; + } + + /** Whether rack {@code seat} carries containers, {@code AddRack} naming {@code container}. */ + public boolean acceptsContainer(ContainerEntity container, int seat) { + AircraftDefinition def = definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + return rack != null && !isPassenger() && !container.isPassenger() + && rack.accepts(ContainerEntity.CARGO_NAME, ContainerEntity.CARGO_NAME); + } + + 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 or container 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()); + boolean loaded = false; + for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { + if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + loaded = true; + break; + } + } + if (!loaded) { + for (ContainerEntity container : level().getEntitiesOfClass(ContainerEntity.class, area)) { + if (acceptsContainer(container, seat) && loadCargo(container, 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.snapTo(at.x, at.y, at.z, getYRot() + rack.yaw(), 0.0F); + // As in the original, with no ground in the 20 blocks below it goes down under a canopy. + if (!(cargo instanceof AircraftEntity) && !hasBlockBelow(3, 20, false)) { + ParachuteEntity.open(level(), cargo, ParachuteEntity.Kind.DROP); + } + 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(); + snapTo(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); + } + + /** + * {@code ejectSeat}: throws the occupant clear of the aircraft in an ejection seat, whose + * parachute opens once it starts to fall. Each ejection takes a parachute from the parachute + * slots of the vehicle screen; without one the seat stays put. Creative players need none. + * Server only. + */ + public void eject(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + if (def == null || !canEject(entity)) { + return; + } + boolean creative = entity instanceof Player player && player.getAbilities().instabuild; + if (!creative && !inventory.takeParachute()) { + if (entity instanceof ServerPlayer player) { + ModSounds.playTo(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.no_parachute")); + } + 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.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.EJECTION_SEAT); + if (canopy != null && !canopy.status()) { + canopy.set(true, false); + modeSwitchCooldown = 40; + } + ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); + } + + // Search lights + + public boolean isSearchLightOn() { + AircraftDefinition def = definition(); + return def != null && !def.searchLights.isEmpty() && entityData.get(DATA_SEARCH_LIGHT); + } + + /** {@code canSwitchSearchLight}: the pilot or the first crew seat, in vehicles that have one. */ + public boolean canSwitchSearchLight(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && !def.searchLights.isEmpty() && seat >= 0 && seat <= 1; + } + + /** Server only. */ + public void toggleSearchLight(Entity entity) { + if (canSwitchSearchLight(entity)) { + entityData.set(DATA_SEARCH_LIGHT, !entityData.get(DATA_SEARCH_LIGHT)); + } + } + + /** + * Where a turning search light points: the first crew seat's view, else the pilot's, as + * {@code renderLight} took them. Null with nobody aboard to aim it. + */ + public @Nullable Entity searchLightAimer() { + Entity crew = occupant(1); + return crew != null ? crew : occupant(0); + } + + // Troops: parachute drops and ropes + + public boolean isRepelling() { + return entityData.get(DATA_REPELLING); + } + + /** How far the ropes hang below their hooks, as a negative number. */ + public float ropeLength() { + return ropeLength; + } + + /** Where rope {@code index} hangs from, in the world. */ + public Vec3 ropePosition(int index) { + AircraftDefinition def = definition(); + return def != null && index >= 0 && index < def.repellingHooks.size() ? toWorld(def.repellingHooks.get(index).position()) : position(); + } + + /** + * {@code onUpdate_Repelling} and {@code onUpdate_UnmountCrew}: the ropes run out towards the + * ground, then the mobs aboard slide down them one per hook interval; a parachute drop lets one + * passenger out per {@code MobDropOption} interval while the hatch is open. + */ + private void tickRopes(AircraftDefinition def) { + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + int clear = airBelow(50); + if (ropeLength > -50.0F && ropeLength > -clear) { + ropeLength -= level().isClientSide() ? 0.3F : 0.25F; + } + } else { + ropeLength = 0.0F; + } + } + if (level().isClientSide()) { + return; + } + if (isRepelling() && isDestroyed()) { + entityData.set(DATA_REPELLING, false); + } + if (!isRepelling()) { + repellingTicks = 0; + } else if (repellingTicks < 60) { + repellingTicks++; + } else { + for (int rope = 0; rope < def.repellingHooks.size(); rope++) { + if (ticksAlive % def.repellingHooks.get(rope).interval() != 0) { + continue; + } + for (int seat = 1; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + rappel(mob, rope); + break; + } + } + } + } + if (droppingTroops) { + if (hasBlockBelow(3, 10, false) || isDestroyed()) { + droppingTroops = false; + } else if ((hatch == null || hatch.isOn()) && ticksAlive % def.dropInterval == 0 && !dropNextTroop(def)) { + droppingTroops = false; + } + } + } + + /** Air blocks straight below, up to {@code max}. */ + private int airBelow(int max) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + int i = 0; + while (i < max && pos.getY() - i > level().getMinY() && level().getBlockState(pos.setY(blockPosition().getY() - i - 1)).isAir()) { + i++; + } + return i; + } + + /** + * {@code canStartRepelling}: a helicopter in hover mode, level and holding still. The original + * compared against where it was a few ticks before. + */ + private boolean canStartRepelling() { + return this instanceof HelicopterEntity && isHoveringMode() && Math.abs(getXRot()) < 3.0F && Math.abs(roll()) < 3.0F + && lastTickPos != null && position().distanceTo(lastTickPos) < 0.05; + } + + /** + * The pilot's drop key, {@code unmountCrew}: with ropes, lets them down from a steady hover + * or pulls them up again; with {@code EnableParachuting}, starts or stops dropping the mobs + * aboard under parachutes; otherwise, or close to the ground, simply lets the mobs out. + * Server only. + * + * @return false if nothing could be done, so the pilot hears it + */ + public boolean dropCrew() { + AircraftDefinition def = definition(); + if (def == null || isDestroyed()) { + return false; + } + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + entityData.set(DATA_REPELLING, false); + return true; + } + if (hasBlockBelow(3, 4, false)) { + return unloadMobs(); + } + if (!canStartRepelling()) { + return false; + } + entityData.set(DATA_REPELLING, true); + repellingTicks = 0; + return true; + } + if (droppingTroops) { + droppingTroops = false; + return true; + } + if (def.parachuting && !hasBlockBelow(3, 10, false)) { + droppingTroops = true; + if (hatch != null && hatch.isOff()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } + return true; + } + return unloadMobs(); + } + + /** Lets every mob in a crew seat out, as in {@code unmountCrew(false)}. */ + private boolean unloadMobs() { + AircraftDefinition def = definition(); + boolean any = false; + for (int seat = 1; def != null && seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + mob.stopRiding(); + any = true; + } + } + return any; + } + + /** The next mob behind the second seat leaves at the drop point under a parachute. */ + private boolean dropNextTroop(AircraftDefinition def) { + for (int seat = 2; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + parachuteOut(mob, def); + return true; + } + } + return false; + } + + private void parachuteOut(Entity entity, AircraftDefinition def) { + Vec3 out = toWorld(def.dropPosition); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement()); + entity.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.DROP); + } + + /** + * A passenger's own drop key, {@code MCH_ClientSeatTickHandler}: slide down a rope once the + * ropes are out, or jump with a parachute from behind the second seat of a vehicle with + * {@code EnableParachuting} and room below. Server only. + * + * @return false if neither is possible right now + */ + public boolean jumpOut(Player player) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (def == null || seat <= 0 || seat >= def.seatCount() || isDestroyed()) { + return false; + } + if (isRepelling() && repellingTicks > 50 && !def.repellingHooks.isEmpty()) { + lastRope = (lastRope + 1) % def.repellingHooks.size(); + rappel(player, lastRope); + return true; + } + if (def.parachuting && seat >= 2 && !hasBlockBelow(3, 13, false)) { + parachuteOut(player, def); + return true; + } + return false; + } + + /** {@code unmountEntityRepelling}: two blocks under the hook, onto a rope rider that lets them down. */ + private void rappel(Entity entity, int rope) { + Vec3 at = ropePosition(rope).add(0.0, -2.0, 0.0); + entity.stopRiding(); + entity.teleportTo(at.x, at.y, at.z); + entity.setDeltaMovement(Vec3.ZERO); + entity.resetFallDistance(); + RappelEntity rider = new RappelEntity(ModEntities.RAPPEL.get(), level()); + rider.attach(this, rope); + rider.setPos(at); + level().addFreshEntity(rider); + entity.startRiding(rider, true, true); + lastRope = rope; + } + + /** + * {@code mountMobToSeats}: when the pilot climbs in, mobs standing close by take the free crew + * seats, so villagers and the like can be flown somewhere. Server only. + */ + private void boardNearbyMobs() { + AircraftDefinition def = definition(); + if (def == null || def.uav) { + return; + } + for (Mob mob : level().getEntitiesOfClass(Mob.class, getBoundingBox().inflate(3.0, 2.0, 3.0), m -> !m.isPassenger() && !m.isVehicle())) { + for (int seat = 1; seat < def.seatCount(); seat++) { + if (isSeatFree(seat)) { + pendingSeat = seat; + mob.startRiding(this, true, true); + pendingSeat = EMPTY; + break; + } + } + } + } + + // 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(@Nullable Entity other) { + 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.hull()) { + Optional hit = clip(hullBox(box).inflate(inflate), from, to); + if (hit.isPresent() && from.distanceToSqr(hit.get()) < bestDistance) { + best = hit; + bestDistance = from.distanceToSqr(hit.get()); + hitBoxFactor = box.damageFactor(); + } + } + } + return best; + } + + /** + * One {@code BoundingBox} in world space. Like the original's, it stays axis aligned: the + * centre turns with the hull, the box itself does not. + */ + private AABB hullBox(BoundingBoxDefinition box) { + Vec3 c = toWorld(box.offset()); + double hw = box.width() / 2.0; + double hh = box.height() / 2.0; + return new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw); + } + + /** The whole hull: the main hit box, every {@code BoundingBox} and the generated boxes, in world space. */ + public List hullBoxes() { + AircraftDefinition def = definition(); + if (def == null || def.hull().isEmpty()) { + return List.of(getBoundingBox()); + } + List boxes = new ArrayList<>(def.hull().size() + 1); + boxes.add(getBoundingBox()); + for (BoundingBoxDefinition box : def.hull()) { + boxes.add(hullBox(box)); + } + return boxes; + } + + /** The smallest box around the whole hull. */ + private AABB hullBounds() { + AABB bounds = getBoundingBox(); + AircraftDefinition def = definition(); + if (def != null) { + for (BoundingBoxDefinition box : def.hull()) { + bounds = bounds.minmax(hullBox(box)); + } + } + return bounds; + } + + /** Whether any part of this hull touches any part of {@code other}'s, grown by {@code inflate}. */ + private boolean hullsTouch(AircraftEntity other, double inflate) { + // Hulls generated from big models run to a thousand boxes, so skip ours that are nowhere near. + AABB around = other.hullBounds().inflate(inflate); + List theirs = null; + for (AABB mine : hullBoxes()) { + AABB grown = mine.inflate(inflate); + if (!grown.intersects(around)) { + continue; + } + if (theirs == null) { + theirs = other.hullBoxes(); + } + for (AABB box : theirs) { + if (grown.intersects(box)) { + return true; + } + } + } + return false; + } + + /** + * Vehicles whose hulls block this one: everything nearby except its own carrier or cargo, and + * vehicles either of the two could load onto a rack, which have to drive into each other. + */ + private List solidNeighbours(AABB area) { + return level().getEntitiesOfClass(AircraftEntity.class, area.inflate(HULL_REACH), other -> other != this && !other.isRemoved() + && other.definition() != null && !other.isPassenger() && !isPassengerOfSameVehicle(other) + && other.getVehicle() != this && getVehicle() != other && !canCarry(other) && !other.canCarry(this)); + } + + /** Whether a free rack of this vehicle would take {@code cargo}. */ + private boolean canCarry(AircraftEntity cargo) { + AircraftDefinition def = definition(); + if (def == null) { + return false; + } + for (int seat = def.seatCount(); seat < def.seatAndRackCount(); seat++) { + if (acceptsCargo(cargo, seat)) { + return true; + } + } + return false; + } + + /** + * Not in the original, where only the main box at a vehicle's feet collided: the whole hull + * blocks other vehicles. A move that would push this hull into another vehicle's is undone, + * sideways first and then completely, so a helicopter can also set down on a parked truck. + * Hulls that already overlapped, say after two vehicles were placed into each other, may move + * apart. + */ + @Override + public void move(MoverType type, Vec3 delta) { + if (type != MoverType.SELF || isPassenger() || definition() == null) { + super.move(type, delta); + return; + } + List near = solidNeighbours(hullBounds().expandTowards(delta)); + if (near.isEmpty()) { + super.move(type, delta); + return; + } + List overlapping = new ArrayList<>(); + for (AircraftEntity other : near) { + if (hullsTouch(other, 0.0)) { + overlapping.add(other); + } + } + Vec3 before = position(); + super.move(type, delta); + if (!entersHull(near, overlapping)) { + return; + } + Vec3 m = getDeltaMovement(); + setPos(before.x, getY(), before.z); + setDeltaMovement(0.0, m.y, 0.0); + if (entersHull(near, overlapping)) { + setPos(before); + setDeltaMovement(Vec3.ZERO); + } + } + + private boolean entersHull(List near, List overlapping) { + for (AircraftEntity other : near) { + if (!overlapping.contains(other) && hullsTouch(other, 0.0)) { + return true; + } + } + return false; + } + + /** 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + float boxFactor = consumeHitBoxFactor(); + if (testFlightStart != null || isRemoved() || isInvulnerableToBase(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() || isHeavy() && isNearGround(3)) { + 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(level, 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; + 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; + } + if (isHeavy()) { + startCookOff(null); + return; + } + exploded = true; + AircraftDefinition def = definition(); + float size = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + Vec3 at = def != null && def.fuelTankPosition != null ? toWorld(def.fuelTankPosition) : position(); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, null, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + if (!isInWater()) { + Explosions.crashFireball(level, at.x, at.y, at.z, size); + } + } + + /** + * Airliners, bombers and transports ({@code MaxFuel} of {@value HEAVY_FUEL} or more): like a + * boat, they go up in three blasts rather than one fireball. + */ + private boolean isHeavy() { + AircraftDefinition def = definition(); + return def != null && def.maxFuel >= HEAVY_FUEL; + } + + /** 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, or a heavy aircraft wrecked on the ground, does not go up in + * one crash fireball; its fuel and magazine cook off in three blasts over about five seconds, + * then a hull in the water 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 { + // A boat or a flying boat goes under; a heavy aircraft on land stays where it burns. + if (isInWater() || waterDepth() > 0.0) { + 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; + crashSpin(); + 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(); + } + } + + /** + * Not in the original: a shot down plane rolls into a nose-down spiral and a helicopter spins + * with some wobble, until it is close to the ground. Tanks and cars just stop. + */ + private void crashSpin() { + if (!(this instanceof HelicopterEntity) && !(this instanceof PlaneEntity) || isNearGround(3) || isInWater()) { + return; + } + int direction = CrashSpin.direction(getId()); + int ticks = spinTicks++; + if (this instanceof HelicopterEntity) { + setYRot(getYRot() + CrashSpin.helicopterYawRate(ticks, direction)); + setXRot(Mth.approach(getXRot(), CrashSpin.helicopterWobblePitch(ticks, getId()), 2.0F)); + roll = Mth.approach(roll, CrashSpin.helicopterWobbleRoll(ticks, getId()), 3.0F); + } else { + setYRot(getYRot() + CrashSpin.planeYawRate(ticks, direction)); + setXRot(CrashSpin.planePitch(getXRot())); + roll = AircraftRotation.wrap(roll + CrashSpin.planeRollRate(ticks, direction)); + } + } + + /** {@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 = def.enginePosition != null ? toWorld(def.enginePosition) : 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); + } + } + + /** A vehicle that is destroyed, packed up or killed spills what it carried; one that unloads keeps it. */ + @Override + public void remove(RemovalReason reason) { + if (reason.shouldDestroy() && !level().isClientSide() && !isRemoved()) { + inventory.dropAll(); + } + super.remove(reason); + } + + @Override + protected Item getDropItem() { + AircraftDefinition def = definition(); + return def != null ? ModItems.forKind(def.kind()) : Items.AIR; + } + + @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()); + } + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putString("Type", typeName()); + output.putString("Texture", texture()); + output.putInt("Hp", hp()); + output.putInt("Fuel", fuel()); + output.putFloat("Roll", roll); + output.putBoolean("Hovering", isHoveringMode()); + output.putBoolean("GunnerMode", isGunnerMode()); + output.putInt("Parts", partBits()); + output.putInt("DespawnCount", despawnCount); + output.putInt("CookOff", cookOffBlasts); + output.putBoolean("Sinking", entityData.get(DATA_SINKING)); + output.putBoolean("SearchLight", entityData.get(DATA_SEARCH_LIGHT)); + if (weapons != null) { + output.putIntArray("WeaponsAmmo", weapons.saveAmmo()); + } + inventory.save(output); + if (station != null && stationReturn != null) { + output.store("Station", UUIDUtil.CODEC, station); + output.store("StationReturn", Vec3.CODEC, stationReturn); + } + ValueOutput.TypedOutputList seats = output.list("Seats", Codec.STRING); + for (int i = 0; i < seatIds.length; i++) { + Entity e = occupant(i); + seats.add(e != null ? e.getUUID().toString() : ""); + } + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + entityData.set(DATA_TYPE, input.getStringOr("Type", "")); + entityData.set(DATA_TEXTURE, input.getStringOr("Texture", "")); + entityData.set(DATA_PARTS, input.getIntOr("Parts", 0)); + AircraftDefinition def = definition(); + entityData.set(DATA_HP, input.getIntOr("Hp", def != null ? def.maxHp : 1)); + // 1.0.0 and the earlier betas had no fuel; their vehicles come back with a full tank. + entityData.set(DATA_FUEL, input.getIntOr("Fuel", def != null ? def.maxFuel : 0)); + roll = input.getFloatOr("Roll", 0.0F); + setHovering(input.getBooleanOr("Hovering", false)); + entityData.set(DATA_GUNNER_MODE, input.getBooleanOr("GunnerMode", false)); + despawnCount = input.getIntOr("DespawnCount", 0); + if (hp() <= 0 && despawnCount <= 0) { + despawnCount = WRECK_TICKS; + } + cookOffBlasts = input.getIntOr("CookOff", 0); + cookOffDelay = cookOffBlasts > 0 ? 20 : 0; + entityData.set(DATA_SINKING, input.getBooleanOr("Sinking", false)); + entityData.set(DATA_SEARCH_LIGHT, input.getBooleanOr("SearchLight", false)); + exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); + input.getIntArray("WeaponsAmmo").ifPresent(ammo -> { + if (weapons != null) { + weapons.loadAmmo(ammo); + } else { + savedAmmo = ammo; + } + }); + inventory.load(input); + station = input.read("Station", UUIDUtil.CODEC).orElse(null); + stationReturn = input.read("StationReturn", Vec3.CODEC).orElse(null); + savedSeats.clear(); + List seats = input.listOrEmpty("Seats", Codec.STRING).stream().toList(); + for (int i = 0; i < seats.size(); i++) { + if (!seats.get(i).isEmpty()) { + try { + savedSeats.put(UUID.fromString(seats.get(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; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java new file mode 100644 index 00000000..eda5ed7b --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java new file mode 100644 index 00000000..4aaab44d --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java @@ -0,0 +1,193 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.UUIDUtil; +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.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 net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * A tow chain, {@code MCH_EntityChain}: it hangs above the vehicle that pulls and drags the towed + * one along, letting it drift up to the chain's length away and never more than two blocks past + * that. It goes when either end does. + */ +public class ChainEntity extends Entity { + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 15; + /** Ticks a chain loaded from disk waits for both ends to load before it gives up. */ + private static final int SEARCH_TICKS = 50; + + private static final EntityDataAccessor DATA_TOW = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TOWED = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + + private @Nullable UUID towId; + private @Nullable UUID towedId; + private @Nullable Entity tow; + private @Nullable Entity towed; + private int length = 4; + private int searchTicks = SEARCH_TICKS; + + public ChainEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + /** Chains {@code towed} to {@code tow}, at the distance they are apart now. Server only. */ + public static ChainEntity connect(Level level, Entity towed, Entity tow) { + ChainEntity chain = new ChainEntity(ModEntities.CHAIN.get(), level); + chain.setPos(tow.position().add(towed.position()).scale(0.5)); + chain.length = Mth.clamp((int) towed.distanceTo(tow), MIN_LENGTH, MAX_LENGTH); + chain.attach(towed, tow); + level.addFreshEntity(chain); + ModSounds.playAt(chain.tow, "chain_ct", 1.0F, 1.0F); + return chain; + } + + /** Whether {@code entity} can pull the other end: only vehicles can. */ + public static boolean canTow(Entity entity) { + return entity instanceof AircraftEntity; + } + + /** + * Only a vehicle pulls. Hooked the other way round, a container would hold a helicopter down, + * so the ends swap whichever order they were clicked in (and chains saved that way are fixed + * when they load). + */ + private void attach(Entity towed, Entity tow) { + if (!canTow(tow) && canTow(towed)) { + Entity swap = tow; + tow = towed; + towed = swap; + } + this.towed = towed; + this.tow = tow; + this.towedId = towed.getUUID(); + this.towId = tow.getUUID(); + entityData.set(DATA_TOWED, towed.getId()); + entityData.set(DATA_TOW, tow.getId()); + searchTicks = 0; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_TOW, -1); + builder.define(DATA_TOWED, -1); + } + + /** The end that pulls; on a client, as far as it has loaded. */ + public @Nullable Entity tow() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOW)) : tow; + } + + /** The end that is pulled. */ + public @Nullable Entity towed() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOWED)) : towed; + } + + /** Whether {@code entity} is either end of this chain. */ + public boolean holds(Entity entity) { + return entity == tow() || entity == towed(); + } + + /** Takes the chain off, with the sound of it coming loose. Server only. */ + public void disconnect() { + ModSounds.playAt(this, "chain_ct", 1.0F, 1.0F); + discard(); + } + + /** The chain on {@code entity}, at either end, if there is one within reach. */ + public static @Nullable ChainEntity on(Entity entity) { + for (ChainEntity chain : entity.level().getEntitiesOfClass(ChainEntity.class, entity.getBoundingBox().inflate(25.0))) { + if (chain.holds(entity)) { + return chain; + } + } + return null; + } + + @Override + public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel server)) { + return; + } + if (searchTicks > 0) { + searchTicks--; + Entity foundTowed = towedId != null ? server.getEntity(towedId) : null; + Entity foundTow = towId != null ? server.getEntity(towId) : null; + if (foundTowed != null && foundTow != null) { + attach(foundTowed, foundTow); + } + return; + } + if (tow == null || towed == null || tow.isRemoved() || towed.isRemoved() || tow == towed) { + disconnect(); + return; + } + towed.resetFallDistance(); + setPos(tow.getX(), tow.getY() + 2.0, tow.getZ()); + pull(tow, towed); + } + + /** {@code updateTowingEntityPosRot}: turns the towed end to face along the chain and drags it in. */ + private void pull(Entity tow, Entity towed) { + Vec3 d = towed.position().subtract(tow.position()); + double dist = d.length(); + if (dist <= length) { + return; + } + towed.setYRot((float) Math.toDegrees(Math.atan2(d.z, d.x)) + 90.0F); + double slack = dist - length; + towed.setDeltaMovement(towed.getDeltaMovement().subtract(d.scale(0.001 / slack))); + double max = length + 2.0; + if (dist > max) { + Vec3 at = tow.position().add(d.scale(max / dist)); + towed.setPos(at.x, at.y, at.z); + } + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + towId = input.read("Tow", UUIDUtil.CODEC).orElse(null); + towedId = input.read("Towed", UUIDUtil.CODEC).orElse(null); + length = Mth.clamp(input.getIntOr("Length", 4), MIN_LENGTH, MAX_LENGTH); + searchTicks = SEARCH_TICKS; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + if (towId != null && towedId != null) { + output.store("Tow", UUIDUtil.CODEC, towId); + output.store("Towed", UUIDUtil.CODEC, towedId); + } + output.putInt("Length", length); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java new file mode 100644 index 00000000..28f14851 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackSoundPacket; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; + +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) { + } + + @Override + public void onPackSound(PackSoundPacket 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); + + void onPackSound(PackSoundPacket packet); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java new file mode 100644 index 00000000..76880875 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java @@ -0,0 +1,152 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.network.chat.Component; +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.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A cargo container, {@code MCH_EntityContainer}: a crate with a large chest's worth of room that + * sits on the ground, floats, can be pushed around, and rides the racks of the vehicles that carry + * {@code container}. Dropped from a rack high enough it comes down under a parachute. + */ +public class ContainerEntity extends Entity { + /** What racks call it in their {@code AddRack} lines. */ + public static final String CARGO_NAME = "container"; + private static final int SLOTS = 54; + /** Punches add up; the original broke it past 40 with 20 per point of damage. */ + private static final int BREAK_DAMAGE = 40; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final SimpleContainer items = new SimpleContainer(SLOTS); + private int damageTaken; + + public ContainerEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide() || isPassenger()) { + return; + } + if (damageTaken > 0) { + damageTaken--; + } + Vec3 m = getDeltaMovement(); + double my = m.y; + if (isInWater() || isInLava()) { + // Bobs up to the surface rather than sinking, as the original's boat physics did. + my = (my < 0.0 ? my / 2.0 : my) + (isEyeInFluid(FluidTags.WATER) ? 0.04 : 0.007); + } else { + my -= 0.04; + } + double mx = m.x; + double mz = m.z; + if (onGround()) { + mx *= 0.9; + mz *= 0.9; + } + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (player.getItemInHand(hand).getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (player.isSecondaryUseActive()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider((id, inventory, p) -> ChestMenu.sixRows(id, inventory, items), + Component.translatable("entity.mcheli.container"))); + } + return InteractionResult.SUCCESS; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || !(source.getEntity() instanceof Player player)) { + // Only players take it apart; explosions and stray rounds leave the cargo alone. + return false; + } + ModSounds.playAt(this, "hit", 1.0F, 1.3F); + damageTaken += (int) (damage * 20.0F); + boolean creative = player.getAbilities().instabuild; + if (creative || damageTaken > BREAK_DAMAGE) { + if (!creative) { + spawnAtLocation(level, ModItems.CONTAINER.get()); + } + Containers.dropContents(level, this, items); + discard(); + } + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean isPushable() { + return !isPassenger(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return !isPassenger(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.CONTAINER.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + items.clearContent(); + ContainerHelper.loadAllItems(input, items.getItems()); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + ContainerHelper.saveAllItems(output, items.getItems(), false); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java new file mode 100644 index 00000000..6b25a388 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java new file mode 100644 index 00000000..aa13dba7 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java @@ -0,0 +1,213 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.weapon.CasStrike; +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.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The ground laser target designator, {@code MCH_EntityGLTD}: a tripod a player sits behind. Its + * sight looks up to 70 degrees either side of where it was set up, zooms and has night vision, + * and marking a spot calls in an A-10 for three gun runs over it. + */ +public class GltdEntity extends Entity { + /** How far the sight turns from the tripod's heading, either way, and up or down. */ + public static final float VIEW_LIMIT = 70.0F; + /** The CAS weapon the A-10s fire; the original hard coded it too. */ + private static final String WEAPON = "a10gau8"; + /** Added to the weapon's own {@code Delay} between two calls. */ + private static final int EXTRA_WAIT = 150; + private static final double SIGHT_RANGE = 300.0; + + private static final EntityDataAccessor DATA_WAIT = SynchedEntityData.defineId(GltdEntity.class, EntityDataSerializers.INT); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final CasStrike strike = new CasStrike(WEAPON); + private boolean nightVision; + + public GltdEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WAIT, 0); + } + + /** Ticks until the next A-10 can be called; the sight shows it. */ + public int waitTicks() { + return entityData.get(DATA_WAIT); + } + + /** The eye of the sight, a little in front of and above the tripod. */ + public Vec3 sightPosition(float partialTicks) { + float yaw = getYRot(partialTicks) * Mth.DEG_TO_RAD; + Vec3 pos = getPosition(partialTicks); + return new Vec3(pos.x - Mth.sin(yaw) * 0.6, pos.y + 0.7, pos.z + Mth.cos(yaw) * 0.6); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.04, 0.0); + if (onGround()) { + m = m.scale(0.5); + } + move(MoverType.SELF, m); + setDeltaMovement(m.x * 0.99, m.y * 0.95, m.z * 0.99); + + if (waitTicks() > 0) { + entityData.set(DATA_WAIT, waitTicks() - 1); + } + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (def != null && level() instanceof ServerLevel server) { + strike.tick(server, def); + } + Entity viewer = getFirstPassenger(); + if (viewer == null) { + nightVision = false; + } else if (nightVision) { + NightVision.topUp(viewer); + } + } + + /** + * {@code useCurrentWeapon}: marks the block {@code player} looks at through the sight and calls + * the A-10 on it. Server only. + * + * @return whether the strike was called; the sight may be waiting, or see nothing in range + */ + public boolean callStrike(ServerPlayer player) { + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (getFirstPassenger() != player || waitTicks() > 0 || def == null || !(level() instanceof ServerLevel server)) { + return false; + } + Vec3 from = sightPosition(1.0F); + Vec3 to = from.add(player.getLookAngle().scale(SIGHT_RANGE)); + BlockHitResult hit = server.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + strike.start(hit.getLocation(), player.getYRot(), this, player); + entityData.set(DATA_WAIT, def.delay + EXTRA_WAIT); + ModSounds.playAt(server, getX(), getY(), getZ(), "gltd", 0.5F, 1.0F); + if (def.sound != null) { + ModSounds.playTo(player, def.sound, 1.0F, 1.0F); + } + return true; + } + + public void setNightVision(Player player, boolean on) { + if (getFirstPassenger() != player) { + return; + } + nightVision = on; + if (!on) { + NightVision.end(player); + } + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (player.isSecondaryUseActive() || isVehicle()) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + player.setYRot(getYRot()); + player.setXRot(0.0F); + player.startRiding(this); + } + return InteractionResult.SUCCESS; + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (!level().isClientSide() && nightVision) { + NightVision.end(passenger); + nightVision = false; + } + } + + /** The viewer sits behind the tripod. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, -0.5).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + /** The original clamped the viewer's head the same way every tick. */ + @Override + public void onPassengerTurned(Entity passenger) { + float yaw = getYRot() + Mth.clamp(Mth.wrapDegrees(passenger.getYRot() - getYRot()), -VIEW_LIMIT, VIEW_LIMIT); + passenger.setYRot(yaw); + passenger.setYHeadRot(yaw); + passenger.setXRot(Mth.clamp(passenger.getXRot(), -VIEW_LIMIT, VIEW_LIMIT)); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved()) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + if (!creative) { + spawnAtLocation(level, ModItems.GLTD.get()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), "hit", 1.0F, 1.0F); + discard(); + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.GLTD.get()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java new file mode 100644 index 00000000..30290141 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java new file mode 100644 index 00000000..957bf9ef --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -0,0 +1,553 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; +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.entity.EntityType; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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.
  • + *
  • Blades fold and unfold one after another at the same pace on both sides, from the + * synced fold state, instead of each client stepping its own copy.
  • + *
+ */ +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 static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + + private double currentSpeed = MIN_SPEED; + private float rotorAngle; + public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; + + 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; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, input.getBooleanOr("FoldBlade", false) ? FOLDED : UNFOLDED); + } + + /** 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() && canUseBlades()) { + setHovering(true); + } + } + + private boolean isLevelEnough() { + return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; + } + + @Override + public boolean canSwitchGunnerMode() { + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); + } + + @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; + } + // Standing on its skids or wheels a helicopter stays level; the mouse only tilts it in the air. + if (!isHovering() && !onGround()) { + 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 (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } + 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; + tickBlades(def); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 00000000..f52c4227 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 00000000..f7a7db39 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java @@ -0,0 +1,188 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A parachute with someone hanging under it, {@code MCH_EntityParachute}. It sinks at about two + * blocks a second, the rider steers it with the forward key, and it folds away a moment after it + * touches the ground or the water. + */ +public class ParachuteEntity extends Entity { + /** What the parachute looks like and how it opens, the original's model type. */ + public enum Kind { + /** From the parachute item: open from the start. */ + ITEM, + /** An ejection seat: the rider falls in the seat and the canopy opens once it drops. */ + EJECTION_SEAT, + /** Troops dropped from a rack or a cargo door: open from the start, no seat. */ + DROP + } + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(ParachuteEntity.class, EntityDataSerializers.INT); + private static final double MAX_SPEED = 0.35; + private static final double MIN_STEER = 0.07; + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private double steer = MIN_STEER; + private int onGroundCount; + /** Who boards on the next server tick; boarding in the same tick as the spawn can be lost. */ + private @Nullable Entity user; + + public ParachuteEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Opens a parachute above {@code user}, carrying over their speed, and puts them under it. + * Server only. + */ + public static ParachuteEntity open(Level level, Entity user, Kind kind) { + ParachuteEntity parachute = new ParachuteEntity(ModEntities.PARACHUTE.get(), level); + double lift = kind == Kind.ITEM ? 3.5 : 0.0; + parachute.setPos(user.getX(), user.getY() + lift, user.getZ()); + parachute.setYRot(user.getYRot()); + parachute.setDeltaMovement(user.getDeltaMovement()); + parachute.entityData.set(DATA_KIND, kind.ordinal()); + parachute.user = user; + user.resetFallDistance(); + level.addFreshEntity(parachute); + return parachute; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, Kind.ITEM.ordinal()); + } + + public Kind kind() { + Kind[] kinds = Kind.values(); + int i = entityData.get(DATA_KIND); + return i >= 0 && i < kinds.length ? kinds[i] : Kind.ITEM; + } + + /** An ejection seat only opens its canopy once it has started to fall. */ + public boolean isOpen() { + return kind() != Kind.EJECTION_SEAT || getDeltaMovement().y < -0.1; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + if (level().isClientSide()) { + return; + } + if (user != null) { + if (!user.isPassenger() && user.isAlive()) { + user.startRiding(this, true, true); + setYRot(user.getYRot()); + } + user = null; + } + Entity rider = getFirstPassenger(); + if (rider == null && tickCount > 20 || isInWater()) { + ejectPassengers(); + discard(); + return; + } + if (rider != null) { + rider.resetFallDistance(); + } + simulate(rider); + } + + /** {@code onUpdateServer}. */ + private void simulate(@Nullable Entity rider) { + Vec3 m = getDeltaMovement(); + double mx = m.x; + double my = m.y; + double mz = m.z; + double prevSpeed = Math.sqrt(mx * mx + mz * mz); + boolean seatLaunch = kind() == Kind.EJECTION_SEAT && tickCount < 20; + my -= onGround() || seatLaunch ? 0.01 : 0.03; + + if (isOpen()) { + if (rider instanceof ServerPlayer player && player.getLastClientInput().forward()) { + double yaw = Math.toRadians(rider.getYRot()); + mx += -Math.sin(yaw) * steer * 0.05; + mz += Math.cos(yaw) * steer * 0.05; + } + double speed = Math.sqrt(mx * mx + mz * mz); + if (speed > MAX_SPEED) { + mx *= MAX_SPEED / speed; + mz *= MAX_SPEED / speed; + speed = MAX_SPEED; + } + // Holding course builds up speed; letting go settles back to a slow drift. + if (speed > prevSpeed) { + steer = Math.min(steer + (MAX_SPEED - steer) / 35.0, MAX_SPEED); + } else { + steer = Math.max(steer - (steer - MIN_STEER) / 35.0, MIN_STEER); + } + } + + if (onGround() && ++onGroundCount > 5) { + ejectPassengers(); + discard(); + return; + } + + move(MoverType.SELF, new Vec3(mx, my, mz)); + my *= seatLaunch ? 0.95 : 0.9; + double drag = isOpen() ? 0.95 : 0.99; + setDeltaMovement(mx * drag, my, mz * drag); + if (rider != null) { + setYRot(rider.getYRot()); + } + } + + /** + * The server moves the parachute; clients ease towards each position it sends over three + * ticks, as for the vehicles. Without this a slow descent steps from one update to the next. + */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + entityData.set(DATA_KIND, input.getIntOr("Kind", Kind.ITEM.ordinal())); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("Kind", kind().ordinal()); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/PartState.java new file mode 100644 index 00000000..54755d56 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java new file mode 100644 index 00000000..b80171b8 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 00000000..e5781817 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,106 @@ +package com.jnhprojects.mcheli.entity; + +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java new file mode 100644 index 00000000..e729c7f5 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java new file mode 100644 index 00000000..92e8e59e --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 00000000..221c9563 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,339 @@ +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.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.chat.Component; +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.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

The original kept the player at the station and had the drone read its pilot from there. + * Here the pilot takes the drone's reserved seat for as long as they fly it, hidden and unhurt, + * so the drone uses the same controls, cameras, HUD and weapons as every other vehicle. Leaving + * the drone, or losing it, puts the pilot back beside the station. + */ +public class UavStationEntity extends Entity { + public static final int STATION = 1; + public static final int PORTABLE = 2; + /** Further out than this, measured flat, the link breaks and the drone is lost, as in the original. */ + public static final double RANGE = 123.0; + /** The launch offset goes up to this far from the station along each axis. */ + public static final int MAX_OFFSET = 50; + + private static final EntityDataAccessor DATA_KIND = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor DATA_OPEN = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_OFFSET = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BLOCK_POS); + private static final EntityDataAccessor DATA_LINKED = SynchedEntityData.defineId(UavStationEntity.class, EntityDataSerializers.BOOLEAN); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private final SimpleContainer slot = new SimpleContainer(1); + private @Nullable UUID lastUav; + /** The lid of the portable controller, 0 shut to 1 open. */ + private float cover; + private float previousCover; + + public UavStationEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_KIND, (byte) STATION); + builder.define(DATA_OPEN, true); + builder.define(DATA_OFFSET, new BlockPos(12, 2, 12)); + builder.define(DATA_LINKED, false); + } + + public int kind() { + return entityData.get(DATA_KIND); + } + + public void setKind(int kind) { + entityData.set(DATA_KIND, (byte) (kind == PORTABLE ? PORTABLE : STATION)); + } + + public boolean isOpen() { + return entityData.get(DATA_OPEN); + } + + /** Where a launched drone appears, relative to the station. */ + public BlockPos offset() { + return entityData.get(DATA_OFFSET); + } + + public void setOffset(BlockPos offset) { + entityData.set(DATA_OFFSET, new BlockPos(Mth.clamp(offset.getX(), -MAX_OFFSET, MAX_OFFSET), + Mth.clamp(offset.getY(), -MAX_OFFSET, MAX_OFFSET), Mth.clamp(offset.getZ(), -MAX_OFFSET, MAX_OFFSET))); + } + + /** Whether a drone is flown from here right now; the screen lights up. */ + public boolean isLinked() { + return entityData.get(DATA_LINKED); + } + + public void setLinked(boolean linked) { + entityData.set(DATA_LINKED, linked); + } + + public SimpleContainer slot() { + return slot; + } + + public float cover(float partialTicks) { + return Mth.lerp(partialTicks, previousCover, cover); + } + + /** + * {@code initUavPostion}: the first launch offset is twelve blocks out on the diagonal the + * station faces, two up. + */ + public void initOffset() { + int quarter = (int) (Mth.positiveModulo(getYRot() + 45.0F, 360.0F) / 90.0F) % 4; + int x = quarter == 0 || quarter == 3 ? 12 : -12; + int z = quarter == 0 || quarter == 1 ? 12 : -12; + setOffset(new BlockPos(x, 2, z)); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + previousCover = cover; + cover = Mth.clamp(cover + (isOpen() ? 0.1F : -0.1F), 0.0F, 1.0F); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.03, 0.0); + move(MoverType.SELF, new Vec3(0.0, m.y, 0.0)); + setDeltaMovement(0.0, onGround() ? 0.0 : m.y * 0.96, 0.0); + + Entity user = getFirstPassenger(); + if (user instanceof ServerPlayer player && !slot.getItem(0).isEmpty()) { + launch(player, slot.getItem(0)); + } + } + + /** What the slot holds, if it is a vehicle at all. */ + public static @Nullable AircraftDefinition definitionOf(ItemStack stack) { + String name = AircraftItem.vehicleOf(stack); + return name != null ? MCHeli.content().any(name) : null; + } + + /** Whether this station can put up the vehicle in {@code stack}. */ + public boolean accepts(@Nullable AircraftDefinition def) { + return def != null && def.uav && (kind() != PORTABLE || def.smallUav); + } + + /** {@code handleItem}: puts up the drone in the slot and hands it to {@code player}. */ + private void launch(ServerPlayer player, ItemStack stack) { + AircraftDefinition def = definitionOf(stack); + if (!accepts(def) || !(level() instanceof ServerLevel server)) { + return; + } + if (ServerConfig.isDisabled(def.name())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName())); + return; + } + AircraftEntity uav = ModEntities.typeFor(def).create(server, EntitySpawnReason.TRIGGERED); + if (uav == null) { + return; + } + BlockPos o = offset(); + double y = Math.max(getY() + o.getY(), server.getMinY() + 2.0); + uav.setType(def); + uav.snapTo(getX() + o.getX(), y, getZ() + o.getZ(), getYRot() - 180.0F, 0.0F); + if (!server.noCollision(uav, uav.getBoundingBox().deflate(0.1))) { + player.sendOverlayMessage(Component.translatable("message.mcheli.uav.blocked")); + slot.setItem(0, ItemStack.EMPTY); + Containers.dropItemStack(server, player.getX(), player.getY(), player.getZ(), stack); + return; + } + slot.setItem(0, ItemStack.EMPTY); + uav.setFuelLevel(def.targetDrone ? uav.maxFuel() : (int) (uav.maxFuel() * 0.05F)); + server.addFreshEntity(uav); + if (!def.targetDrone) { + player.closeContainer(); + control(player, uav); + } + } + + /** {@code controlLastAircraft}: takes the last drone flown from here again. */ + public boolean continueControl(ServerPlayer player) { + AircraftEntity uav = lastUav(); + if (uav == null || getFirstPassenger() != player || !uav.canTakeRemotePilot()) { + return false; + } + player.closeContainer(); + return control(player, uav); + } + + public @Nullable AircraftEntity lastUav() { + if (lastUav == null || !(level() instanceof ServerLevel server)) { + return null; + } + Entity e = server.getEntity(lastUav); + return e instanceof AircraftEntity uav && uav.isAlive() && !uav.isDestroyed() && inRange(uav) ? uav : null; + } + + public boolean inRange(Entity uav) { + double dx = uav.getX() - getX(); + double dz = uav.getZ() - getZ(); + return dx * dx + dz * dz <= RANGE * RANGE; + } + + private boolean control(ServerPlayer player, AircraftEntity uav) { + player.stopRiding(); + if (!uav.takeRemotePilot(player, this)) { + return false; + } + lastUav = uav.getUUID(); + setLinked(true); + player.setYRot(uav.getYRot()); + return true; + } + + /** Where a pilot comes back to when the drone is left or lost: the seat in front of the station. */ + public Vec3 returnPosition() { + float yaw = getYRot() * Mth.DEG_TO_RAD; + return new Vec3(getX() - Mth.sin(yaw) * 0.9, getY(), getZ() + Mth.cos(yaw) * 0.9); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (isVehicle()) { + return InteractionResult.PASS; + } + if (kind() == PORTABLE && player.isSecondaryUseActive()) { + if (!level().isClientSide()) { + entityData.set(DATA_OPEN, !isOpen()); + } + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive() || kind() == PORTABLE && !isOpen()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer server) { + player.setYRot(getYRot() - 180.0F); + if (player.startRiding(this)) { + server.openMenu(new SimpleMenuProvider((id, inventory, p) -> new UavStationMenu(id, inventory, this), + Component.translatable(kind() == PORTABLE ? "item.mcheli.uav_station2" : "item.mcheli.uav_station"))); + } + } + return InteractionResult.SUCCESS; + } + + /** The original sat the player down in front of the console, lower for the portable one. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + double y = kind() == PORTABLE ? -0.6 : 0.35; + return new Vec3(0.0, y, 0.9).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || damage <= 0.0F) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + AircraftEntity uav = lastUav(); + if (uav != null) { + uav.releaseRemotePilot(); + } + Containers.dropContents(level, blockPosition(), slot); + if (!creative) { + spawnAtLocation(level, item()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), source.getEntity() instanceof Player ? "hit" : "helidmg", 1.0F, 1.0F); + discard(); + return true; + } + + private Item item() { + return kind() == PORTABLE ? ModItems.UAV_CONTROLLER.get() : ModItems.UAV_STATION.get(); + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return true; + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(item()); + } + + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + setKind(input.getIntOr("Kind", STATION)); + entityData.set(DATA_OPEN, input.getBooleanOr("Open", true)); + setOffset(new BlockPos(input.getIntOr("UavX", 12), input.getIntOr("UavY", 2), input.getIntOr("UavZ", 12))); + lastUav = input.read("LastUav", UUIDUtil.CODEC).orElse(null); + slot.setItem(0, input.read("Item", ItemStack.CODEC).orElse(ItemStack.EMPTY)); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("Kind", kind()); + output.putBoolean("Open", isOpen()); + BlockPos o = offset(); + output.putInt("UavX", o.getX()); + output.putInt("UavY", o.getY()); + output.putInt("UavZ", o.getZ()); + if (lastUav != null) { + output.store("LastUav", UUIDUtil.CODEC, lastUav); + } + if (!slot.getItem(0).isEmpty()) { + output.store("Item", ItemStack.CODEC, slot.getItem(0)); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java new file mode 100644 index 00000000..1fdc978e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/VehicleInventory.java @@ -0,0 +1,145 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.supply.SupplyRules; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** + * What a vehicle carries besides its crew, {@code MCH_AircraftInventory} and the container half of + * {@code W_EntityContainer}: the equipment slots of the vehicle screen (three fuel cans and a + * parachute per ejection seat) and the cargo chest of vehicles with an {@code InventorySize}. + * + *

The cargo chest always has room for the largest size, 54 slots, and the screen shows as many + * rows as the definition asks for. Items in rows a content reload took away are kept rather than + * lost, and still drop when the vehicle is destroyed. + */ +public final class VehicleInventory { + public static final int FUEL_SLOTS = 3; + public static final int PARACHUTE_SLOTS = 2; + public static final int EQUIPMENT_SLOTS = FUEL_SLOTS + PARACHUTE_SLOTS; + public static final int CARGO_SLOTS = 54; + /** How far from the vehicle's centre a player outside may keep a screen open, past its width. */ + private static final double REACH = 12.0; + + private final AircraftEntity vehicle; + private final SimpleContainer equipment = new GuardedContainer(EQUIPMENT_SLOTS); + private final SimpleContainer cargo = new GuardedContainer(CARGO_SLOTS); + + VehicleInventory(AircraftEntity vehicle) { + this.vehicle = vehicle; + } + + public SimpleContainer equipment() { + return equipment; + } + + public SimpleContainer cargo() { + return cargo; + } + + /** {@code isUseableByPlayer}: the crew, or someone standing next to the vehicle. */ + public boolean stillValid(Player player) { + if (vehicle.isRemoved() || vehicle.definition() == null) { + return false; + } + if (player.getVehicle() == vehicle) { + return true; + } + double reach = REACH + vehicle.definition().bodyWidth / 2.0; + return player.distanceToSqr(vehicle) <= reach * reach; + } + + // Fuel + + /** + * {@code updateFuel}: every half second, while the vehicle stands on something, each fuel can + * in a fuel slot pours up to 100 units into the tank. Server only. + * + * @return whether any fuel went in + */ + boolean pourFuelCans(int ticks) { + if (ticks % SupplyRules.FUEL_SLOT_INTERVAL != 0 || !vehicle.canSupply()) { + return false; + } + boolean poured = false; + for (int slot = 0; slot < FUEL_SLOTS; slot++) { + ItemStack can = equipment.getItem(slot); + if (!(can.getItem() instanceof FuelItem)) { + continue; + } + int amount = SupplyRules.fuelFromCan(vehicle.maxFuel() - vehicle.fuel(), FuelItem.contents(can)); + if (amount > 0) { + vehicle.refuel(amount); + can.setDamageValue(can.getDamageValue() + amount); + poured = true; + } + } + if (poured) { + equipment.setChanged(); + } + return poured; + } + + // Parachutes + + /** Parachute slots the vehicle screen offers: one per ejection seat. */ + public static int parachuteSlots(AircraftDefinition def) { + return Math.min(def.ejectionSeats(), PARACHUTE_SLOTS); + } + + /** + * {@code consumeParachute}: takes one parachute out of the parachute slots for a crew member + * who bails out. + * + * @return false when there is none left + */ + boolean takeParachute() { + for (int slot = FUEL_SLOTS; slot < EQUIPMENT_SLOTS; slot++) { + if (equipment.getItem(slot).getItem() instanceof ParachuteItem) { + equipment.removeItem(slot, 1); + return true; + } + } + return false; + } + + // Saving and dropping + + void save(ValueOutput output) { + ContainerHelper.saveAllItems(output.child("Equipment"), equipment.getItems(), false); + ContainerHelper.saveAllItems(output.child("Cargo"), cargo.getItems(), false); + } + + void load(ValueInput input) { + equipment.clearContent(); + cargo.clearContent(); + input.child("Equipment").ifPresent(in -> ContainerHelper.loadAllItems(in, equipment.getItems())); + input.child("Cargo").ifPresent(in -> ContainerHelper.loadAllItems(in, cargo.getItems())); + } + + /** {@code setDead}: everything aboard falls out where the vehicle was. Server only. */ + void dropAll() { + Containers.dropContents(vehicle.level(), vehicle, equipment); + Containers.dropContents(vehicle.level(), vehicle, cargo); + } + + /** A container that only stays open for players {@link #stillValid(Player)} allows. */ + private final class GuardedContainer extends SimpleContainer { + GuardedContainer(int size) { + super(size); + } + + @Override + public boolean stillValid(Player player) { + return VehicleInventory.this.stillValid(player); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java new file mode 100644 index 00000000..46ed88a8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/VehicleParticles.java @@ -0,0 +1,180 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.ParticleSplash; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.part.MovingPart; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * The spray, dust and exhaust a vehicle throws up, all client side: + *

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java new file mode 100644 index 00000000..e72e952e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java @@ -0,0 +1,160 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +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.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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(), EntitySpawnReason.TRIGGERED); + 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 hurtServer(ServerLevel level, 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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java new file mode 100644 index 00000000..a9d41520 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java new file mode 100644 index 00000000..e5f46c96 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java new file mode 100644 index 00000000..d7031ecf --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java new file mode 100644 index 00000000..c758d75b --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java new file mode 100644 index 00000000..b2b418ac --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java new file mode 100644 index 00000000..69a2f674 --- /dev/null +++ b/minecraft-26.2/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.getMinY() || pos.getY() >= server.getMaxY()) { + 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-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java new file mode 100644 index 00000000..a564a1fc --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java @@ -0,0 +1,161 @@ +package com.jnhprojects.mcheli.entity.projectile; + +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.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.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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) { + snapTo(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 hurtServer(ServerLevel level, 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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java new file mode 100644 index 00000000..eb723310 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java @@ -0,0 +1,139 @@ +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.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; + +/** + * 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(rgb, 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(), EntitySpawnReason.TRIGGERED); + 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().getMaxY(); 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 hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public String defaultModel() { + return "rocket"; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java new file mode 100644 index 00000000..30f171c5 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -0,0 +1,790 @@ +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.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.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +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); + /** Who fired, for clients: their rounds must not stop on their own vehicle. */ + private static final EntityDataAccessor DATA_SHOOTER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_USER = SynchedEntityData.defineId(ProjectileEntity.class, EntityDataSerializers.INT); + + /** 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; + /** Client only: the round hit something here and waits, hidden, for the server to remove it. */ + private boolean spent; + + 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); + builder.define(DATA_SHOOTER, 0); + builder.define(DATA_USER, 0); + } + + /** + * 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) { + snapTo(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; + setOwners(shooter, 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; + setOwners(parent.shooter, parent.user); + } + + public void setOwners(@Nullable Entity shooter, @Nullable Entity user) { + this.shooter = shooter; + this.user = user; + entityData.set(DATA_SHOOTER, shooter != null ? shooter.getId() : 0); + entityData.set(DATA_USER, user != null ? user.getId() : 0); + } + + 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 (spent) { + return; + } + 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().getMaxY() && !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(), EntitySpawnReason.TRIGGERED); + 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; + } + + /** Clients only learn who fired through synched data; look them up once it has arrived. */ + private void resolveOwners() { + if (shooter == null && entityData.get(DATA_SHOOTER) > 0) { + shooter = level().getEntity(entityData.get(DATA_SHOOTER)); + } + if (user == null && entityData.get(DATA_USER) > 0) { + user = level().getEntity(entityData.get(DATA_USER)); + } + } + + /** {@code canBeCollidedEntity}: never the shooter, its crew, or rounds fired by the same side. */ + public boolean canHit(Entity entity) { + if (level().isClientSide()) { + resolveOwners(); + } + 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); + } + if (piercing <= 0) { + // The server decides what the hit does; until its removal arrives the round stays + // where it struck instead of flying on through the hull. + spent = true; + setPos(hit.location()); + setDeltaMovement(Vec3.ZERO); + } + 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 hurtServer(ServerLevel level, 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; + } + + /** Client only: whether the round already hit something and should no longer be drawn. */ + public boolean isSpent() { + return spent; + } + + @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(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putString("WeaponName", weaponName()); + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java new file mode 100644 index 00000000..c39fa6fe --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 00000000..479ead5a --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,200 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +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.util.ARGB; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private final InterpolationHandler interpolation = new InterpolationHandler(this, 3); + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + interpolation.interpolate(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = ARGB.redFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float g = ARGB.greenFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float b = ARGB.blueFloat(color) * 0.9F + random.nextFloat() * 0.1F; + if (ARGB.red(color) == ARGB.green(color)) { + g = r; + } + if (ARGB.red(color) == ARGB.blue(color)) { + b = r; + } + if (ARGB.green(color) == ARGB.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(ARGB.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + public InterpolationHandler getInterpolation() { + return interpolation; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java new file mode 100644 index 00000000..a89f7c01 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java new file mode 100644 index 00000000..a06fef1a --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 00000000..7b04e4a4 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,146 @@ +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.content.pack.PackManifest; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.ChatFormatting; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; +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.function.Consumer; + +/** + * 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; 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)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + 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); + } + + /** Who made a vehicle from a content pack. The import's vehicles have their credits in the docs. */ + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, Consumer out, TooltipFlag flag) { + super.appendHoverText(stack, context, display, out, flag); + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + PackManifest credit = def != null ? def.credit() : null; + if (credit != null) { + out.accept((credit.author().isEmpty() + ? Component.translatable("item.mcheli.vehicle.from_pack", credit.name()) + : Component.translatable("item.mcheli.vehicle.from_pack_by", credit.name(), credit.author())) + .withStyle(ChatFormatting.GRAY)); + } + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { + return InteractionResult.PASS; + } + + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + if (!level.isClientSide()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.unknown", String.valueOf(name))); + } + return InteractionResult.FAIL; + } + if (!level.isClientSide() && ServerConfig.isDisabled(def.name())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName())); + return InteractionResult.FAIL; + } + + AircraftEntity vehicle = ModEntities.typeFor(def).create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (vehicle == null) { + return InteractionResult.FAIL; + } + Vec3 at = blockHit.getLocation(); + vehicle.setType(def); + vehicle.snapTo(at.x, at.y, at.z, player.getYRot(), 0.0F); + if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { + return InteractionResult.FAIL; + } + + 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 InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 00000000..2f18676a --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.removed")); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName())); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.released")); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance))); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.no_vehicle")); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName())); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 00000000..efa8f295 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; + +/** + * The fuel can, {@code MCH_ItemFuel}. It holds 600 units; the durability bar shows how much is + * left. Using it refills it from the coal and charcoal in the inventory, as in the original. + * + *

A can in a fuel slot of the vehicle screen empties into the tank while the vehicle stands on + * something, as in the original. Not in the original: right-clicking a vehicle with the can pours + * up to 100 units at once, which saves opening the screen. + */ +public class FuelItem extends Item { + /** Units a full can holds, the original's maximum damage. */ + public static final int CAPACITY = 600; + /** Units poured into a vehicle per click: what the original moved per fuel slot per update. */ + private static final int POUR = 100; + private static final int PER_COAL = 100; + private static final int PER_CHARCOAL = 75; + + public FuelItem(Properties properties) { + super(properties.durability(CAPACITY)); + } + + public static int contents(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + /** Fills a vehicle's tank from the can. Server only. */ + public static InteractionResult pour(ItemStack stack, AircraftEntity vehicle, Player player) { + int added = vehicle.refuel(player.getAbilities().instabuild ? POUR : Math.min(contents(stack), POUR)); + if (added <= 0) { + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) { + stack.setDamageValue(stack.getDamageValue() + added); + } + vehicle.level().playSound(null, vehicle.getX(), vehicle.getY(), vehicle.getZ(), SoundEvents.BUCKET_EMPTY, SoundSource.NEUTRAL, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } + + /** Charcoal first, then coal, each one worth part of the can, until it is full. */ + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + if (!stack.isDamaged() || player.getAbilities().instabuild) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + refill(stack, player.getInventory(), Items.CHARCOAL, PER_CHARCOAL); + refill(stack, player.getInventory(), Items.COAL, PER_COAL); + } + return InteractionResult.SUCCESS; + } + + private static void refill(ItemStack can, Inventory inventory, Item coal, int worth) { + for (int slot = 0; slot < inventory.getContainerSize() && can.isDamaged(); slot++) { + ItemStack item = inventory.getItem(slot); + while (item.is(coal) && !item.isEmpty() && can.isDamaged()) { + can.setDamageValue(Math.max(can.getDamageValue() - worth, 0)); + item.shrink(1); + } + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 00000000..a83ff5bd --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 00000000..0a286838 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java @@ -0,0 +1,61 @@ +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.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +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, ServerLevel level, Entity owner, @Nullable EquipmentSlot slot) { + if (!(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-26.2/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 00000000..2af7b79b --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.Level; + +/** The parachute, {@code MCH_ItemParachute}: used in mid-air, it opens above the player. */ +public class ParachuteItem extends Item { + public ParachuteItem(Properties properties) { + super(properties.stacksTo(1)); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (player.isPassenger() || player.onGround()) { + return InteractionResult.PASS; + } + if (!level.isClientSide()) { + ParachuteEntity.open(level, player, ParachuteEntity.Kind.ITEM); + player.getItemInHand(hand).consume(1, player); + } + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 00000000..34c524d5 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.snapTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 00000000..6a4fb996 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.SPYGLASS; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 00000000..df8f33f8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,123 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResult.FAIL; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } + + @Override + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return false; + } + int drawn = Math.max(getUseDuration(stack, entity) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + return true; + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 00000000..7e3bc1c8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 00000000..c2619049 --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 00000000..8ad8f03f --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +/** + * The wrench, {@code MCH_ItemWrench}. Right-clicking a damaged vehicle repairs 10 hit points, once + * a second, at one point of the wrench's durability. Sneaking switches the vehicle to its next + * paint scheme. + * + *

The original repaired while the use key was held; a click per repair works the same with + * today's item use, which stops as soon as the cursor leaves the hull. + */ +public class WrenchItem extends Item { + /** An iron tool's durability, the original's tool material. */ + private static final int DURABILITY = 250; + private static final int REPAIR = 10; + private static final int COOLDOWN = 20; + + public WrenchItem(Properties properties) { + super(properties.durability(DURABILITY)); + } + + /** Server only. */ + public static InteractionResult useOn(ItemStack stack, AircraftEntity vehicle, Player player, InteractionHand hand) { + if (player.isSecondaryUseActive()) { + return vehicle.nextTexture() ? InteractionResult.SUCCESS : InteractionResult.FAIL; + } + if (player.getCooldowns().isOnCooldown(stack) || !vehicle.repair(REPAIR)) { + return InteractionResult.FAIL; + } + player.getCooldowns().addCooldown(stack, COOLDOWN); + stack.hurtAndBreak(1, player, hand); + ModSounds.playAt(vehicle, "wrench", 1.0F, 0.9F + vehicle.getRandom().nextFloat() * 0.2F); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 00000000..ec1b4d89 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,148 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes in the player's recipe book and asks for one by its + * display id; the server finds the recipe, takes the ingredients from the inventory and puts the + * result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addStandardInventorySlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, RecipeDisplayId id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeManager.ServerDisplayInfo info = player.level().getServer().getRecipeManager().getRecipeFromDisplay(id); + if (info == null) { + return; + } + RecipeHolder holder = info.parent(); + if (!holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + List ingredients = recipe.placementInfo().ingredients(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.assemble(CraftingInput.EMPTY)); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 00000000..4c5f7bd8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addStandardInventorySlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java new file mode 100644 index 00000000..b2dba1e4 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/menu/VehicleMenu.java @@ -0,0 +1,217 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.VehicleInventory; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.weapon.Resupply; +import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The vehicle screen, {@code MCH_AircraftGuiContainer}: fuel slots, a parachute slot per ejection + * seat, reloading the weapons from the crew member's own items and the way into the cargo chest. + * + *

Only crew open it, so the client finds the vehicle as the one its player rides. The weapon + * rounds and which weapons can be reloaded right now come from the server through data slots, + * since a client only hears about the ammunition of the seat it sits in. + */ +public class VehicleMenu extends AbstractContainerMenu { + /** Weapons the screen can show; more than any vehicle in the original has. */ + public static final int MAX_WEAPONS = 32; + /** Button ids from zero up reload that weapon; this one opens the cargo chest. */ + public static final int BUTTON_CARGO = 1000; + + public static final int FUEL_X = 8; + public static final int FUEL_Y = 30; + public static final int PARACHUTE_X = 8; + public static final int PARACHUTE_Y = 66; + public static final int INVENTORY_Y = 102; + + private static final int DATA_RELOADABLE = MAX_WEAPONS; + private static final int DATA_SIZE = MAX_WEAPONS + 2; + + private final @Nullable AircraftEntity vehicle; + private final Container equipment; + private final ContainerData data; + private final int parachuteSlots; + private boolean supplied; + + /** The client side, from the open screen packet. */ + public VehicleMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof AircraftEntity v ? v : null, + new SimpleContainer(VehicleInventory.EQUIPMENT_SLOTS), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public VehicleMenu(int containerId, Inventory inventory, AircraftEntity vehicle) { + this(containerId, inventory, vehicle, vehicle.inventory().equipment(), new ServerData(vehicle, inventory.player)); + } + + private VehicleMenu(int containerId, Inventory inventory, @Nullable AircraftEntity vehicle, Container equipment, ContainerData data) { + super(ModMenus.VEHICLE.get(), containerId); + this.vehicle = vehicle; + this.equipment = equipment; + this.data = data; + AircraftDefinition def = vehicle != null ? vehicle.definition() : null; + parachuteSlots = def != null ? VehicleInventory.parachuteSlots(def) : 0; + checkContainerSize(equipment, VehicleInventory.EQUIPMENT_SLOTS); + + for (int i = 0; i < VehicleInventory.FUEL_SLOTS; i++) { + addSlot(new Slot(equipment, i, FUEL_X + i * 18, FUEL_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return stack.getItem() instanceof FuelItem; + } + }); + } + for (int i = 0; i < VehicleInventory.PARACHUTE_SLOTS; i++) { + boolean active = i < parachuteSlots; + addSlot(new Slot(equipment, VehicleInventory.FUEL_SLOTS + i, PARACHUTE_X + i * 18, PARACHUTE_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return active && stack.getItem() instanceof ParachuteItem; + } + + @Override + public boolean isActive() { + return active; + } + }); + } + addStandardInventorySlots(inventory, 8, INVENTORY_Y); + addDataSlots(data); + } + + public @Nullable AircraftEntity vehicle() { + return vehicle; + } + + /** Parachute slots in use: one per ejection seat. */ + public int parachuteSlots() { + return parachuteSlots; + } + + /** Magazine and reserve of a weapon together, as the server last reported them. */ + public int rounds(int weapon) { + return weapon >= 0 && weapon < MAX_WEAPONS ? data.get(weapon) : 0; + } + + /** Whether the server would reload this weapon now: the vehicle is down and the items are there. */ + public boolean canReload(int weapon) { + if (weapon < 0 || weapon >= MAX_WEAPONS) { + return false; + } + int bits = (data.get(DATA_RELOADABLE) & 0xFFFF) | (data.get(DATA_RELOADABLE + 1) & 0xFFFF) << 16; + return (bits & 1 << weapon) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (vehicle == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CARGO) { + return vehicle.openCargo(server); + } + if (id >= 0 && id < MAX_WEAPONS && Resupply.supply(server, vehicle, id)) { + supplied = true; + return true; + } + return false; + } + + @Override + public boolean stillValid(Player player) { + return vehicle != null && equipment.stillValid(player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + int equipmentEnd = VehicleInventory.EQUIPMENT_SLOTS; + if (index < equipmentEnd) { + if (!moveItemStackTo(stack, equipmentEnd, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof FuelItem) { + if (!moveItemStackTo(stack, 0, VehicleInventory.FUEL_SLOTS, false)) { + return ItemStack.EMPTY; + } + } else if (stack.getItem() instanceof ParachuteItem && parachuteSlots > 0) { + int first = VehicleInventory.FUEL_SLOTS; + if (!moveItemStackTo(stack, first, first + parachuteSlots, false)) { + return ItemStack.EMPTY; + } + } else { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** As in the original, closing the screen after a resupply loads every magazine from its reserve. */ + @Override + public void removed(Player player) { + super.removed(player); + if (supplied && vehicle != null && vehicle.weapons() != null && !player.level().isClientSide()) { + vehicle.weapons().reloadAll(); + } + } + + /** Rounds per weapon and the reload bits, worked out on the server whenever the menu syncs. */ + private record ServerData(AircraftEntity vehicle, Player player) implements ContainerData { + @Override + public int get(int index) { + VehicleWeapons weapons = vehicle.weapons(); + if (weapons == null) { + return 0; + } + if (index < MAX_WEAPONS) { + WeaponSet set = weapons.set(index); + // Data slots travel as shorts. + return set != null ? Math.min(set.ammo() + set.restAmmo(), Short.MAX_VALUE) : 0; + } + int bits = 0; + if (player instanceof ServerPlayer server) { + for (int i = 0; i < Math.min(weapons.sets().size(), MAX_WEAPONS); i++) { + if (Resupply.canSupply(server, vehicle, i)) { + bits |= 1 << i; + } + } + } + return index == DATA_RELOADABLE ? bits & 0xFFFF : bits >>> 16; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java new file mode 100644 index 00000000..ba56ca3e --- /dev/null +++ b/minecraft-26.2/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.interact(sender, InteractionHand.MAIN_HAND, msg.location.subtract(vehicle.position())); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java new file mode 100644 index 00000000..1b565e21 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -0,0 +1,164 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +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, + /** Open the vehicle screen: fuel and parachute slots, reloading, the way to the cargo chest. */ + VEHICLE_SCREEN, + /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ + GUNNER_MODE, + /** Turn night vision on for the sender, in vehicles with {@code EnableNightVision}. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT + } + + 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 VEHICLE_SCREEN -> vehicle.openScreen(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 00000000..46294aa3 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +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.item.crafting.display.RecipeDisplayId; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe the player's recipe book knows by this display id. + */ +public record DraftingTablePacket(RecipeDisplayId recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + RecipeDisplayId.STREAM_CODEC.encode(buf, msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(RecipeDisplayId.STREAM_CODEC.decode(buf)); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 00000000..76090f2e --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +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 request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket 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 equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java new file mode 100644 index 00000000..279b1511 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java new file mode 100644 index 00000000..0056dcc0 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 00000000..d3f204c7 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,119 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +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.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.level(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED) + : ModEntities.AA_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java new file mode 100644 index 00000000..97d2cbf4 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 00000000..1de0bfc3 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +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 client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java new file mode 100644 index 00000000..26065705 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -0,0 +1,90 @@ +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 = 11; + + /** 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<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), + new Serverbound<>(TestFlightPacket.TYPE, TestFlightPacket.STREAM_CODEC, TestFlightPacket::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), + new Serverbound<>(PackSubmitPacket.TYPE, PackSubmitPacket.STREAM_CODEC, PackSubmitPacket::handle)); + + /** Server to client. Handlers touch client classes, so they only ever run on a client. */ + public static final List> CLIENTBOUND = List.of( + 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), + new Clientbound<>(PackSoundPacket.TYPE, PackSoundPacket.STREAM_CODEC, PackSoundPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::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.2/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 00000000..89c1644d --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 00000000..ad7f0c85 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 00000000..50f10f74 --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java new file mode 100644 index 00000000..8bac5034 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSoundPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to player: play a sound a content pack brought, once, at a point. The game's own sound + * packet ends at the sound manager, which only knows the sounds in {@code sounds.json}, and a pack + * has none: it would be silent. See {@code ModSounds}. + */ +public record PackSoundPacket(String sound, double x, double y, double z, float volume, float pitch) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_sound"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSoundPacket::encode, PackSoundPacket::decode); + + private static final int MAX_NAME = 256; + + public static void encode(PackSoundPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.sound, MAX_NAME); + buf.writeDouble(msg.x); + buf.writeDouble(msg.y); + buf.writeDouble(msg.z); + buf.writeFloat(msg.volume); + buf.writeFloat(msg.pitch); + } + + public static PackSoundPacket decode(FriendlyByteBuf buf) { + return new PackSoundPacket(buf.readUtf(MAX_NAME), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readFloat(), buf.readFloat()); + } + + public static void handle(PackSoundPacket msg) { + ClientAccess.packs().onPackSound(msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java new file mode 100644 index 00000000..b25eba0a --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSubmissions.java @@ -0,0 +1,185 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.content.pack.PendingPacks; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Players offering a server the vehicles they built, for its operators to approve. + *

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

Everything here runs on the server thread except the check, which reads the whole pack. + */ +public final class PackSubmissions { + private static final int MAX_UPLOADS = 4; + private static final long COOLDOWN_MILLIS = 30_000L; + + private static final Map UPLOADS = new HashMap<>(); + private static final Map LAST_SUBMISSION = new HashMap<>(); + + private PackSubmissions() { + } + + static void receive(ServerPlayer player, PackSubmitPacket packet) { + UUID id = player.getUUID(); + if (packet.offset() == 0) { + UPLOADS.remove(id); + Component refusal = refusal(player, packet.total()); + if (refusal != null) { + player.sendSystemMessage(refusal); + return; + } + UPLOADS.put(id, new ByteArrayOutputStream()); + } + ByteArrayOutputStream upload = UPLOADS.get(id); + // No upload means it was refused above and the rest of its chunks are still on their way. + if (upload == null) { + return; + } + long end = (long) upload.size() + packet.data().length; + if (packet.offset() != upload.size() || packet.total() > ServerConfig.maxPackBytes() || end > packet.total()) { + UPLOADS.remove(id); + player.sendSystemMessage(Component.translatable("message.mcheli.submit.failed", "chunks arrived out of order")); + return; + } + upload.write(packet.data(), 0, packet.data().length); + if (end == packet.total()) { + UPLOADS.remove(id); + LAST_SUBMISSION.put(id, System.currentTimeMillis()); + check(player, upload.toByteArray()); + } + } + + private static @Nullable Component refusal(ServerPlayer player, int total) { + if (!ServerConfig.allowSubmissions()) { + return Component.translatable("message.mcheli.submit.off"); + } + if (total <= 0 || total > ServerConfig.maxPackBytes()) { + return Component.translatable("message.mcheli.submit.too_large", ServerConfig.maxPackBytes() >> 20); + } + if (UPLOADS.size() >= MAX_UPLOADS) { + return Component.translatable("message.mcheli.submit.busy"); + } + long now = System.currentTimeMillis(); + LAST_SUBMISSION.values().removeIf(time -> now - time >= COOLDOWN_MILLIS); + return LAST_SUBMISSION.containsKey(player.getUUID()) ? Component.translatable("message.mcheli.submit.busy") : null; + } + + private static void check(ServerPlayer player, byte[] zip) { + MinecraftServer server = player.level().getServer(); + UUID id = player.getUUID(); + String name = player.getScoreboardName(); + CompletableFuture.supplyAsync(() -> { + try { + return MCHeli.packs().pending().store(zip, name, id); + } catch (IOException | PackException | RuntimeException e) { + throw new java.util.concurrent.CompletionException(e); + } + }, Util.nonCriticalIoPool()).whenComplete((submission, failure) -> server.execute(() -> { + if (failure != null) { + Throwable cause = failure.getCause() != null ? failure.getCause() : failure; + MCHeli.LOGGER.info("{} sent a pack that was refused: {}", name, cause.toString()); + tell(server, id, Component.translatable("message.mcheli.submit.failed", cause.getMessage() != null ? cause.getMessage() : cause.toString())); + } else { + arrived(server, submission); + } + })); + } + + private static void arrived(MinecraftServer server, PendingPacks.Submission submission) { + MCHeli.LOGGER.info("{} submitted content pack {} {} ({})", submission.submitter(), submission.id(), + submission.manifest().version(), ContentPack.shortHash(submission.hash())); + // Even with approval off, a pack taking over one that is in use waits for a person. + boolean replaces = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (!ServerConfig.requireApproval() && !replaces) { + try { + install(server, submission); + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not install submitted content pack {}", submission.id(), e); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.failed", e.getMessage())); + } + return; + } + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.waiting", submission.id())); + Component note = Component.translatable("message.mcheli.submit.to_operators", submission.submitter(), submission.id(), submission.manifest().version()); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PackSync.seesProblems(player)) { + player.sendSystemMessage(note); + } + } + } + + /** + * Puts a waiting pack in use: into {@code mcheli/packs}, then a reload that sends every player + * the new list. + * + * @return whether the pack is in use now; the server's allow list or size limits may leave it out + */ + public static boolean install(MinecraftServer server, PendingPacks.Submission submission) throws IOException, PackException { + MCHeli.packs().pending().install(submission, MCHeli.packs().folder()); + ContentReload.reload(server); + MCHeli.LOGGER.info("Content pack {} from {} approved", submission.id(), submission.submitter()); + boolean live = MCHeli.packs().active().stream().anyMatch(pack -> pack.id().equals(submission.id())); + if (live) { + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.approved", submission.id())); + } + return live; + } + + /** Throws a waiting pack away and tells the player who sent it, if they are here. */ + public static void reject(MinecraftServer server, PendingPacks.Submission submission) throws IOException { + MCHeli.packs().pending().remove(submission); + MCHeli.LOGGER.info("Content pack {} from {} rejected", submission.id(), submission.submitter()); + tell(server, submission.submitterId(), Component.translatable("message.mcheli.submit.rejected", submission.id())); + } + + private static void tell(MinecraftServer server, UUID player, Component message) { + ServerPlayer online = server.getPlayerList().getPlayer(player); + if (online != null) { + online.sendSystemMessage(message); + } + } + + /** An operator joining hears what is waiting for them. */ + static void onLogin(ServerPlayer player) { + if (!PackSync.seesProblems(player)) { + return; + } + try { + List waiting = MCHeli.packs().pending().list(); + if (!waiting.isEmpty()) { + player.sendSystemMessage(Component.translatable("message.mcheli.submit.waiting_for_you", waiting.size())); + } + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not list the submitted content packs", e); + } + } + + static void onLogout(ServerPlayer player) { + UPLOADS.remove(player.getUUID()); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java new file mode 100644 index 00000000..d142e700 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSubmitPacket.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.network; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * Player to server: one chunk of a pack the player made, offered to the server's operators. See + * {@link PackSubmissions}, which treats all of this as untrusted. + * + * @param offset where this chunk goes; 0 starts a new submission + * @param total length of the whole zip + */ +public record PackSubmitPacket(int offset, int total, byte[] data) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("pack_submit"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(PackSubmitPacket::encode, PackSubmitPacket::decode); + + /** Under the 32 KiB limit of a serverbound custom payload, which is far lower than the other way. */ + public static final int CHUNK_SIZE = 30 * 1024; + + public static void encode(PackSubmitPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackSubmitPacket decode(FriendlyByteBuf buf) { + return new PackSubmitPacket(buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackSubmitPacket msg, ServerPlayer sender) { + PackSubmissions.receive(sender, msg); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 00000000..b3e53269 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,158 @@ +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); + } + PackSubmissions.onLogin(player); + } + + public static void onLogout(ServerPlayer player) { + TRANSFERS.remove(player.getUUID()); + PackSubmissions.onLogout(player); + } + + /** The packs the server uses now, as the client should load them. */ + 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.nameAndId()) + || Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(player.createCommandSourceStack()); + } + + 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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java new file mode 100644 index 00000000..6426f060 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 00000000..1ee72286 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +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.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 00000000..14b2db42 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +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 button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java new file mode 100644 index 00000000..db12a390 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/TestFlightPacket.java @@ -0,0 +1,34 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.workshop.TestFlight; +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; + +/** The workshop's Test flight button: fly the vehicle just saved. The server checks who is asking. */ +public record TestFlightPacket(String vehicle) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("test_flight"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TestFlightPacket::encode, TestFlightPacket::decode); + private static final int MAX_NAME = 64; + + public static void encode(TestFlightPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.vehicle, MAX_NAME); + } + + public static TestFlightPacket decode(FriendlyByteBuf buf) { + return new TestFlightPacket(buf.readUtf(MAX_NAME)); + } + + public static void handle(TestFlightPacket msg, ServerPlayer sender) { + if (sender != null) { + TestFlight.start(sender, msg.vehicle); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java new file mode 100644 index 00000000..292f643e --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 00000000..63a3577f --- /dev/null +++ b/minecraft-26.2/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( + ExtraCodecs.RGB_COLOR_CODEC.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-26.2/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 00000000..29a0b0ef --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +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 puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + ExtraCodecs.RGB_COLOR_CODEC.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/platform/Platform.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/platform/Platform.java new file mode 100644 index 00000000..e951f496 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/platform/Registered.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/platform/Registered.java new file mode 100644 index 00000000..3d1b9085 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/platform/Registration.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/platform/Registration.java new file mode 100644 index 00000000..030a07dd --- /dev/null +++ b/minecraft-26.2/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.2/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 00000000..b03984ce --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.entity.BlockEntityType; + +import java.util.Set; + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> new BlockEntityType<>(DraftingTableBlockEntity::new, Set.of(ModBlocks.DRAFTING_TABLE.get()))); + + private ModBlockEntities() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 00000000..19995a92 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of().setId(key) + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java new file mode 100644 index 00000000..870aa8b8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -0,0 +1,72 @@ +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.item.ThrowableItem; +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, and one for + * the equipment and throwables. Those lists are 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)); + public static final Registered EQUIPMENT = Registration.register(Registries.CREATIVE_MODE_TAB, "equipment", + key -> Platform.get().creativeTab(key(VEHICLES)) + .title(Component.translatable("itemGroup.mcheli.equipment")) + .icon(() -> new ItemStack(ModItems.WRENCH.get())) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) + .build()); + + 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(), EQUIPMENT.get()); + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 00000000..377a5783 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,34 @@ +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()); + + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + 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.2/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java new file mode 100644 index 00000000..b6686ccd --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -0,0 +1,170 @@ +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.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.entity.GroundVehicleEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.ParachuteEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; +import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; +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.ThrowableEntity; +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)); + + public static final Registered> PARACHUTE = Registration.register(Registries.ENTITY_TYPE, "parachute", key -> EntityType.Builder + .of(ParachuteEntity::new, MobCategory.MISC) + .sized(1.5F, 0.6F) + .clientTrackingRange(10) + .updateInterval(1) + .build(key)); + + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key)); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key)); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key)); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key)); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .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.2/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 00000000..e7415dfd --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,102 @@ +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.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; +import com.jnhprojects.mcheli.item.WrenchItem; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; + +import java.util.List; +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. The + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. + */ +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"); + + public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties().setId(key))); + public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties().setId(key))); + public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties().setId(key))); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties().setId(key))); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties().setId(key))); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties().setId(key))); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties().setId(key).useBlockDescriptionPrefix())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties().setId(key))); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); + + 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) || EQUIPMENT.stream().anyMatch(e -> e.id().getPath().equals(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.2/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java new file mode 100644 index 00000000..0dac0052 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -0,0 +1,27 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.menu.VehicleMenu; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.MenuType; + +/** Our screens with slots. The cargo chest uses vanilla's chest menus and needs no entry here. */ +public final class ModMenus { + public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", + key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); + + private ModMenus() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 00000000..4ed53acd --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +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; + } + }); + + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 00000000..4d869373 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 00000000..b6816296 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,113 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; +import net.minecraft.world.scores.TeamColor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER); + } + + /** Switches the {@code pvp} game rule in every dimension and says which way it went. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !player.level().getGameRules().get(GameRules.PVP); + for (ServerLevel level : server.getAllLevels()) { + level.getGameRules().set(GameRules.PVP, pvp, server); + } + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.team.no_teams")); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.sendOverlayMessage(Component.translatable("message.mcheli.team.bad_name", trimmed)); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(Optional.of(TeamColor.VALUES.get(Math.floorMod(color, TeamColor.VALUES.size())))); + team.setAllowFriendlyFire(friendlyFire); + player.sendOverlayMessage(Component.translatable("message.mcheli.team.created", trimmed)); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + target.teleport(target.findRespawnPositionAndUseSpawnBlock(false, TeleportTransition.DO_NOTHING)); + } + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.sendOverlayMessage(Component.translatable("message.mcheli.team.destroyed", count)); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 00000000..e78278a8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,125 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + 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, EntitySpawnReason.TRIGGERED); + if (a10 == null) { + return; + } + a10.setPos(x, y, z); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java new file mode 100644 index 00000000..204fe93f --- /dev/null +++ b/minecraft-26.2/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.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 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 level)) { + return false; + } + if (target instanceof LivingEntity living) { + living.invulnerableTime = 0; + } + 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(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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java new file mode 100644 index 00000000..4f4b0dc8 --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java new file mode 100644 index 00000000..f7fe3e89 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -0,0 +1,217 @@ +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.EntitySpawnReason; +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.ServerExplosion; +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, EntitySpawnReason.TRIGGERED); + 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 = ServerExplosion.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-26.2/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java new file mode 100644 index 00000000..12ddc658 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java @@ -0,0 +1,195 @@ +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.entity.EntitySpawnReason; +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(), EntitySpawnReason.TRIGGERED); + 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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java new file mode 100644 index 00000000..b8142e92 --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java new file mode 100644 index 00000000..f763f73f --- /dev/null +++ b/minecraft-26.2/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.Identifier; +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; + }; + Identifier key = Identifier.tryParse(id.contains(":") ? id : "minecraft:" + id); + if (key == null) { + return null; + } + Item item = BuiltInRegistries.ITEM.getValue(key); + return item == Items.AIR ? null : item; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java new file mode 100644 index 00000000..b4007926 --- /dev/null +++ b/minecraft-26.2/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.Identifier; +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, Identifier.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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java new file mode 100644 index 00000000..533f5fba --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java @@ -0,0 +1,82 @@ +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 a crew member's own items, the {@code supplyAmmo} path of the + * original's vehicle screen. One supply costs the weapon's {@code Item} lines and adds + * {@code SuppliedNum} rounds; it only works while the vehicle stands on something. + */ +public final class Resupply { + private Resupply() { + } + + /** {@code canPlayerSupplyAmmo}: whether {@link #supply} would do anything right now. */ + public static boolean canSupply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + VehicleWeapons weapons = vehicle.weapons(); + WeaponSet set = weapons != null ? weapons.set(weapon) : null; + if (set == null || vehicle.isDestroyed() || player.getVehicle() != vehicle || !vehicle.canSupply()) { + return false; + } + if (set.ammo() + set.restAmmo() >= set.maxAmmo()) { + return false; + } + return player.getAbilities().instabuild || canPay(player.getInventory(), set.definition()); + } + + /** Pays for and adds one supply of {@code weapon}. Server only. */ + public static boolean supply(ServerPlayer player, AircraftEntity vehicle, int weapon) { + if (!canSupply(player, vehicle, weapon)) { + return false; + } + WeaponSet set = vehicle.weapons().set(weapon); + if (!player.getAbilities().instabuild) { + pay(player.getInventory(), set.definition()); + } + 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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java new file mode 100644 index 00000000..3f867744 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -0,0 +1,418 @@ +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.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.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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, EntitySpawnReason.TRIGGERED); + 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, soundName(set)); + } + + private static String soundName(WeaponSet set) { + return set.ownSound != null ? set.ownSound.sound() : set.definition().sound; + } + + private static void sound(WeaponSet set, Entity at, String name) { + if (set.soundAllowed) { + WeaponDefinition w = set.definition(); + float rnd = w.soundPitchRandom; + float pitch = set.ownSound != null && set.ownSound.pitch() != null ? set.ownSound.pitch() : w.soundPitch; + ModSounds.playAt(at, name, w.soundVolume, pitch * (1.0F - rnd) + RANDOM.nextFloat() * rnd); + } + } + + // 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) { + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? soundName(set) : "ng"); + return true; + } + List spotted = new ArrayList<>(); + 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; + } + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); + } + sound(set, shot.shooter, !spotted.isEmpty() ? soundName(set) : "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; + set.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); + set.casUser = shot.user; + set.casShooter = shot.shooter; + set.casStartTick = CasStrike.FIRST_PASS; + 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; + } + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); + set.casStartTick = set.ticksSinceShot(0) + 45; + set.casCount++; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java new file mode 100644 index 00000000..6a9f53cf --- /dev/null +++ b/minecraft-26.2/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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 00000000..b9331154 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +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 java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.level(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.level(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} diff --git a/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java new file mode 100644 index 00000000..85dee9f8 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -0,0 +1,597 @@ +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.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()) { + WeaponSet set = new WeaponSet(weapon, group.mounts(), clientSide); + set.ownSound = def.weaponSounds.get(group.weaponType()); + sets.add(set); + } + } + 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(rgb, 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, 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(rgb, 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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java new file mode 100644 index 00000000..3f539030 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -0,0 +1,505 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.content.aircraft.WeaponSound; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +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.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; + /** The vehicle's own sound for this weapon, from {@code WeaponSound}. */ + public @Nullable WeaponSound ownSound; + + /** Aim relative to the hull, following the crew member who operates the weapon. */ + public float rotationYaw; + 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); + } + } + + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + + // 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/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java new file mode 100644 index 00000000..f5786b92 --- /dev/null +++ b/minecraft-26.2/src/main/java/com/jnhprojects/mcheli/workshop/TestFlight.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.workshop; + +import com.jnhprojects.mcheli.ContentReload; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; + +/** + * Flying a vehicle straight from the workshop. The pack was just saved on the machine this server + * runs on, so loading it again is enough. That only holds for the owner of a single player or LAN + * game: a dedicated server does not get a pack until an operator approves it, and then it is no + * longer a test, so the screen does not offer it there and the server refuses it too. + */ +public final class TestFlight { + /** How far up to look for room when the vehicle does not fit where the player stands. */ + private static final int MAX_RISE = 48; + + private TestFlight() { + } + + public static void start(ServerPlayer player, String vehicle) { + MinecraftServer server = player.level().getServer(); + if (!server.isSingleplayerOwner(player.nameAndId())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.workshop.test_flight_owner_only")); + return; + } + if (player.isPassenger()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.workshop.test_flight_riding")); + return; + } + ContentReload.reload(server); + AircraftDefinition def = MCHeli.content().any(vehicle); + if (def == null) { + player.sendSystemMessage(Component.translatable("message.mcheli.workshop.test_flight_no_vehicle", vehicle)); + MCHeli.packs().reportProblems(player::sendSystemMessage); + return; + } + ServerLevel level = player.level(); + AircraftEntity aircraft = ModEntities.typeFor(def).create(level, EntitySpawnReason.COMMAND); + if (aircraft == null) { + return; + } + aircraft.setType(def); + Vec3 start = player.position(); + for (int rise = 0; rise <= MAX_RISE; rise++) { + aircraft.snapTo(start.x, start.y + rise, start.z, player.getYRot(), 0.0F); + if (level.noCollision(aircraft, aircraft.getBoundingBox())) { + break; + } + } + aircraft.startTestFlight(start); + level.addFreshEntity(aircraft); + player.startRiding(aircraft, true, true); + } +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-26.2/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 00000000..82992af9 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/items/drafting_table.json b/minecraft-26.2/src/main/resources/assets/mcheli/items/drafting_table.json new file mode 100644 index 00000000..a3afc683 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/items/drafting_table.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "mcheli:block/drafting_table" + } +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/items/helicopter.json b/minecraft-26.2/src/main/resources/assets/mcheli/items/helicopter.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/items/helicopter.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/items/plane.json b/minecraft-26.2/src/main/resources/assets/mcheli/items/plane.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/items/plane.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/items/tank.json b/minecraft-26.2/src/main/resources/assets/mcheli/items/tank.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/items/tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/items/vehicle.json b/minecraft-26.2/src/main/resources/assets/mcheli/items/vehicle.json new file mode 100644 index 00000000..24742eb7 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/items/vehicle.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-26.2/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 00000000..8dfa5bcc --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/particles/glow.json b/minecraft-26.2/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 00000000..296e5397 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-26.2/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 00000000..977381be --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/post_effect/thermal.json b/minecraft-26.2/src/main/resources/assets/mcheli/post_effect/thermal.json new file mode 100644 index 00000000..593e905d --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/post_effect/thermal.json @@ -0,0 +1,38 @@ +{ + "targets": { + "swap": {} + }, + "passes": [ + { + "vertex_shader": "minecraft:core/screenquad", + "fragment_shader": "mcheli:post/thermal", + "inputs": [ + { + "sampler_name": "In", + "target": "minecraft:main" + } + ], + "output": "swap" + }, + { + "vertex_shader": "minecraft:core/screenquad", + "fragment_shader": "minecraft:post/blit", + "inputs": [ + { + "sampler_name": "In", + "target": "swap" + } + ], + "uniforms": { + "BlitConfig": [ + { + "name": "ColorModulate", + "type": "vec4", + "value": [ 1.0, 1.0, 1.0, 1.0 ] + } + ] + }, + "output": "minecraft:main" + } + ] +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/shaders/post/thermal.fsh b/minecraft-26.2/src/main/resources/assets/mcheli/shaders/post/thermal.fsh new file mode 100644 index 00000000..24f82a83 --- /dev/null +++ b/minecraft-26.2/src/main/resources/assets/mcheli/shaders/post/thermal.fsh @@ -0,0 +1,37 @@ +#version 330 + +// Thermal view, white hot. A post shader only sees colours, so heat is faked: bright and warm +// colours (fire, lava, skin, lamps) read hot, the sky and water cold, and outlines are drawn +// where the brightness changes sharply, like the original's "pencil" shader. + +uniform sampler2D InSampler; + +in vec2 texCoord; + +layout(std140) uniform SamplerInfo { + vec2 OutSize; + vec2 InSize; +}; + +out vec4 fragColor; + +const vec3 Gray = vec3(0.3, 0.59, 0.11); + +float heat(vec3 c) { + float luma = dot(c, Gray); + float warmth = clamp(c.r - c.b, 0.0, 1.0); + return clamp(luma * 0.7 + warmth * 0.6, 0.0, 1.0); +} + +void main() { + vec2 oneTexel = 1.0 / InSize; + float c = heat(texture(InSampler, texCoord).rgb); + float l = heat(texture(InSampler, texCoord - vec2(oneTexel.x, 0.0)).rgb); + float r = heat(texture(InSampler, texCoord + vec2(oneTexel.x, 0.0)).rgb); + float u = heat(texture(InSampler, texCoord - vec2(0.0, oneTexel.y)).rgb); + float d = heat(texture(InSampler, texCoord + vec2(0.0, oneTexel.y)).rgb); + float edge = clamp(abs(r - l) + abs(d - u), 0.0, 1.0); + + float value = smoothstep(0.08, 0.85, c) + edge * 0.6; + fragColor = vec4(vec3(clamp(value, 0.0, 1.0)), 1.0); +} diff --git a/minecraft-26.2/src/main/resources/assets/mcheli/textures/particle/glow.png b/minecraft-26.2/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 00000000..339b9729 Binary files /dev/null and b/minecraft-26.2/src/main/resources/assets/mcheli/textures/particle/glow.png differ diff --git a/minecraft-26.2/src/main/resources/data/mcheli/damage_type/bullet.json b/minecraft-26.2/src/main/resources/data/mcheli/damage_type/bullet.json new file mode 100644 index 00000000..92ee6766 --- /dev/null +++ b/minecraft-26.2/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.2/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json b/minecraft-26.2/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json new file mode 100644 index 00000000..610afa08 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/chain.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/chain.json new file mode 100644 index 00000000..484f5463 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/container.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/container.json new file mode 100644 index 00000000..4c3bdcd9 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "#c:chests/wooden", + "I": "#c:ingots/iron" + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/drafting_table.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/drafting_table.json new file mode 100644 index 00000000..f3d6324e --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "minecraft:crafting_table", + "F": "#minecraft:wooden_fences", + "P": "#minecraft:planks", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/fgm148.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fgm148.json new file mode 100644 index 00000000..c2c61fd6 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/fgm148_bullet.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fgm148_bullet.json new file mode 100644 index 00000000..c87b4800 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/fim92.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fim92.json new file mode 100644 index 00000000..33a068e1 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/fim92_bullet.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fim92_bullet.json new file mode 100644 index 00000000..c472fd6e --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/fuel.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fuel.json new file mode 100644 index 00000000..d076b041 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/fuel.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "C": "minecraft:coal", + "I": "#c:ingots/iron" + }, + "pattern": [ + "ICI", + "III" + ], + "result": { + "id": "mcheli:fuel" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/gltd.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/gltd.json new file mode 100644 index 00000000..104ac108 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": "#c:storage_blocks/iron", + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_green.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_green.json new file mode 100644 index 00000000..1046d9cf --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:green_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:item_model": "mcheli:m18_green", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_red.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_red.json new file mode 100644 index 00000000..f98ac51f --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:red_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:item_model": "mcheli:m18_red", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_violet.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_violet.json new file mode 100644 index 00000000..b05f48a7 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:purple_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:item_model": "mcheli:m18_violet", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_yellow.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_yellow.json new file mode 100644 index 00000000..57ecd202 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:yellow_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:item_model": "mcheli:m18_yellow", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/parachute.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/parachute.json new file mode 100644 index 00000000..69eab191 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/parachute.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "S": "#c:strings", + "W": "#minecraft:wool" + }, + "pattern": [ + "WWW", + "S S", + " W " + ], + "result": { + "id": "mcheli:parachute" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/rangefinder.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/rangefinder.json new file mode 100644 index 00000000..58a13385 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json new file mode 100644 index 00000000..d6bac29a --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + "mcheli:rangefinder", + "#c:dusts/redstone" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/uav_station.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/uav_station.json new file mode 100644 index 00000000..24b97d55 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:storage_blocks/redstone" + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/uav_station2.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/uav_station2.json new file mode 100644 index 00000000..74bf0d20 --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +} diff --git a/minecraft-26.2/src/main/resources/data/mcheli/recipe/wrench.json b/minecraft-26.2/src/main/resources/data/mcheli/recipe/wrench.json new file mode 100644 index 00000000..b8c9bcff --- /dev/null +++ b/minecraft-26.2/src/main/resources/data/mcheli/recipe/wrench.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + " I ", + " II", + "I " + ], + "result": { + "id": "mcheli:wrench" + } +} diff --git a/minecraft-26.2/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/minecraft-26.2/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json new file mode 100644 index 00000000..1042db9e --- /dev/null +++ b/minecraft-26.2/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.2/src/main/resources/mcheli_logo.png b/minecraft-26.2/src/main/resources/mcheli_logo.png new file mode 100644 index 00000000..95c72198 Binary files /dev/null and b/minecraft-26.2/src/main/resources/mcheli_logo.png differ diff --git a/minecraft-26.2/src/main/resources/pack.mcmeta b/minecraft-26.2/src/main/resources/pack.mcmeta new file mode 100644 index 00000000..1be5582b --- /dev/null +++ b/minecraft-26.2/src/main/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "${mod_name} resources", + "min_format": 88, + "max_format": 107 + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java index 7fd3b542..60f814a3 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java @@ -38,6 +38,8 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; @@ -50,6 +52,8 @@ import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityType; @@ -117,11 +121,17 @@ public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); } + /** How a loader registers the screen of a menu: vanilla's own method is not public on all of them. */ + public interface MenuScreenRegistrar { + > void register(MenuType type, + MenuScreens.ScreenConstructor screen); + } + /** On the main thread during client setup, once our menu types are registered. */ - public static void registerMenuScreens() { - MenuScreens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); - MenuScreens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); - MenuScreens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::new); + public static void registerMenuScreens(MenuScreenRegistrar screens) { + screens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + screens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + screens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::new); } public static List keyMappings() { diff --git a/minecraft-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 index 6c873144..274b8ac7 100644 --- a/minecraft-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 @@ -55,8 +55,9 @@ protected RuntimeSoundInstance(SoundEvent event, SoundSource source, RandomSourc } /** - * 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. + * NeoForge calls this through its sound instance extension, which it overrides without saying + * so. Fabric has no such method and calls {@link #openStream} from a mixin. Forge declares it + * too but never calls it on this version, see {@code PackSoundBuffers} in its module. */ public CompletableFuture getStream(SoundBufferLibrary buffers, Sound sound, boolean looping) { return openStream(buffers, sound.getPath(), looping); @@ -68,6 +69,11 @@ public CompletableFuture openStream(SoundBufferLibrary buffers, Ide if (source == null) { return buffers.getStream(path, looping); } + return streamFile(source, looping); + } + + /** Streams an OGG file from disk, read as it plays and never cached as a buffer. */ + public static CompletableFuture streamFile(Path source, boolean looping) { return CompletableFuture.supplyAsync(() -> { try { InputStream in = new BufferedInputStream(Files.newInputStream(source)); diff --git a/neoforge-1.20.6/build.gradle b/neoforge-1.20.6/build.gradle index 6dded114..60829c34 100644 --- a/neoforge-1.20.6/build.gradle +++ b/neoforge-1.20.6/build.gradle @@ -7,12 +7,17 @@ plugins { 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() diff --git a/neoforge-1.20.6/gradle.properties b/neoforge-1.20.6/gradle.properties index 14fb2f7c..3e067ad4 100644 --- a/neoforge-1.20.6/gradle.properties +++ b/neoforge-1.20.6/gradle.properties @@ -1,8 +1,7 @@ -# Minecraft 1.20.6 on NeoForge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.20.6 on NeoForge loader=neoforge minecraft_version=1.20.6 minecraft_version_range=[1.20.6,1.20.7) neoforge_version=20.6.144 loader_version_range=[20.6,) java_version=21 -scaffold=true diff --git a/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java index 19b0ef8d..a1a02c70 100644 --- a/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java +++ b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -1,19 +1,93 @@ package com.jnhprojects.mcheli.neoforge; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Placeholder entry point for Minecraft 1.20.6. It only proves that the module builds and loads; - * the NeoForge glue from neoforge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + 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(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); + 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())); + } - public McheliNeoForge() { - LOGGER.info("MCHeli Rework placeholder loaded on NeoForge for Minecraft 1.20.6; no content yet"); + 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.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 00000000..42819997 --- /dev/null +++ b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,122 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.CameraAngles; +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.neoforge.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +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.RegisterMenuScreensEvent; +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.event.ViewportEvent; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$loadEffect(effect)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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(ViewportEvent.ComputeCameraAngles.class, event -> ClientHooks.computeCameraAngles(new Angles(event))); + NeoForge.EVENT_BUS.addListener(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov((float) event.getFOV()))); + 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); + } + } + + /** NeoForge turns the camera the other way round its roll axis than {@link CameraAngles} means, as on every version. */ + 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/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 00000000..ab353589 --- /dev/null +++ b/neoforge-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 00000000..0e7ddc96 --- /dev/null +++ b/neoforge-1.20.6/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.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..ee6b727f --- /dev/null +++ b/neoforge-1.20.6/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#loadEffect} private on 1.20.6. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("loadEffect") + void mcheli$loadEffect(ResourceLocation effect); +} diff --git a/neoforge-1.20.6/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-1.20.6/src/main/resources/META-INF/neoforge.mods.toml index 2585f0b9..ecb64dfe 100644 --- a/neoforge-1.20.6/src/main/resources/META-INF/neoforge.mods.toml +++ b/neoforge-1.20.6/src/main/resources/META-INF/neoforge.mods.toml @@ -13,12 +13,14 @@ 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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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" diff --git a/neoforge-1.20.6/src/main/resources/mcheli.mixins.json b/neoforge-1.20.6/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..ef8e0cae --- /dev/null +++ b/neoforge-1.20.6/src/main/resources/mcheli.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "GameRendererInvoker" + ], + "injectors": { + "defaultRequire": 1 + } +} 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 index 734b0ed6..a1a02c70 100644 --- 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 @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.neoforge; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import com.jnhprojects.mcheli.CommonHooks; import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.item.VehicleIngredient; @@ -40,6 +41,7 @@ public McheliNeoForge(IEventBus modBus) { helper -> helper.register(VehicleIngredient.ID, NeoForgeVehicleIngredient.TYPE)); }); modBus.addListener(RegisterPayloadHandlersEvent.class, McheliNeoForge::registerPackets); + modBus.addListener(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); modBus.addListener(AddPackFindersEvent.class, event -> event.addRepositorySource(CommonHooks.packSource())); NeoForge.EVENT_BUS.addListener(ServerAboutToStartEvent.class, event -> CommonHooks.serverAboutToStart(event.getServer())); 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 index 31b238aa..73c951b7 100644 --- 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 @@ -1,5 +1,10 @@ package com.jnhprojects.mcheli.neoforge; +import net.neoforged.neoforge.client.event.ViewportEvent; +import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; +import net.minecraft.client.Minecraft; +import com.jnhprojects.mcheli.neoforge.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.client.ClientHooks; import com.jnhprojects.mcheli.client.particle.GlowParticle; import com.jnhprojects.mcheli.registry.ModParticles; @@ -27,11 +32,16 @@ private NeoForgeClient() { } static void init(IEventBus modBus) { - ClientHooks.init(); + ClientHooks.init(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$loadEffect(effect)); 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(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.Provider::new); + }); modBus.addListener(RegisterKeyMappingsEvent.class, event -> ClientHooks.keyMappings().forEach(event::register)); modBus.addListener(RegisterClientReloadListenersEvent.class, event -> ClientHooks.reloadListeners().values().forEach(event::registerReloadListener)); @@ -39,6 +49,7 @@ static void init(IEventBus modBus) { 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(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov((float) event.getFOV()))); 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())); diff --git a/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..7a42c1a1 --- /dev/null +++ b/neoforge-1.21.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#loadEffect} private on 1.21.1. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("loadEffect") + void mcheli$loadEffect(ResourceLocation effect); +} diff --git a/neoforge-1.21.1/src/main/resources/mcheli.mixins.json b/neoforge-1.21.1/src/main/resources/mcheli.mixins.json index 19d6b73e..77522ff3 100644 --- a/neoforge-1.21.1/src/main/resources/mcheli.mixins.json +++ b/neoforge-1.21.1/src/main/resources/mcheli.mixins.json @@ -5,6 +5,7 @@ "compatibilityLevel": "JAVA_21", "client": [ "CameraInvoker", + "GameRendererInvoker", "GameRendererMixin" ], "injectors": { diff --git a/neoforge-1.21.11/build.gradle b/neoforge-1.21.11/build.gradle index 6dded114..60829c34 100644 --- a/neoforge-1.21.11/build.gradle +++ b/neoforge-1.21.11/build.gradle @@ -7,12 +7,17 @@ plugins { 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() diff --git a/neoforge-1.21.11/gradle.properties b/neoforge-1.21.11/gradle.properties index 54840e89..1b046e04 100644 --- a/neoforge-1.21.11/gradle.properties +++ b/neoforge-1.21.11/gradle.properties @@ -1,8 +1,9 @@ -# Minecraft 1.21.11 on NeoForge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.21.11 on NeoForge loader=neoforge minecraft_version=1.21.11 minecraft_version_range=[1.21.11,1.21.12) neoforge_version=21.11.48 loader_version_range=[21.11,) java_version=21 -scaffold=true +# NeoForge 1.21.11 pins this version strictly. +jspecify_version=1.0.0 diff --git a/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java index 8244fd29..b5bd9c45 100644 --- a/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java +++ b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -1,19 +1,93 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Placeholder entry point for Minecraft 1.21.11. It only proves that the module builds and loads; - * the NeoForge glue from neoforge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + 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(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); + 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())); + } - public McheliNeoForge() { - LOGGER.info("MCHeli Rework placeholder loaded on NeoForge for Minecraft 1.21.11; no content yet"); + 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.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 00000000..9c8d7d61 --- /dev/null +++ b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.neoforge.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Minecraft; +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.RegisterMenuScreensEvent; +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.event.ViewportEvent; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov(event.getFOV()))); + 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-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 00000000..920adbdb --- /dev/null +++ b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +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; +import java.util.Set; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return new BlockEntityType<>(factory::apply, Set.of(block)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 00000000..195be00a --- /dev/null +++ b/neoforge-1.21.11/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-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java new file mode 100644 index 00000000..4c289d07 --- /dev/null +++ b/neoforge-1.21.11/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.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..635e1a93 --- /dev/null +++ b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 1.21.11. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(Identifier effect); +} diff --git a/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java b/neoforge-1.21.11/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java new file mode 100644 index 00000000..5537f2d6 --- /dev/null +++ b/neoforge-1.21.11/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 = "updateCamera", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Camera;setup(Lnet/minecraft/world/level/Level;Lnet/minecraft/world/entity/Entity;ZZF)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(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-1.21.11/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-1.21.11/src/main/resources/META-INF/neoforge.mods.toml index 2585f0b9..c8095914 100644 --- a/neoforge-1.21.11/src/main/resources/META-INF/neoforge.mods.toml +++ b/neoforge-1.21.11/src/main/resources/META-INF/neoforge.mods.toml @@ -8,17 +8,19 @@ modId="${mod_id}" version="${mod_version}" displayName="${mod_name}" displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" -logoFile="mcheli_logo.png" +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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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" diff --git a/neoforge-1.21.11/src/main/resources/mcheli.mixins.json b/neoforge-1.21.11/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..77522ff3 --- /dev/null +++ b/neoforge-1.21.11/src/main/resources/mcheli.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraInvoker", + "GameRendererInvoker", + "GameRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/neoforge-1.21.5/build.gradle b/neoforge-1.21.5/build.gradle index 6dded114..60829c34 100644 --- a/neoforge-1.21.5/build.gradle +++ b/neoforge-1.21.5/build.gradle @@ -7,12 +7,17 @@ plugins { 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() diff --git a/neoforge-1.21.5/gradle.properties b/neoforge-1.21.5/gradle.properties index 359852a0..99c716db 100644 --- a/neoforge-1.21.5/gradle.properties +++ b/neoforge-1.21.5/gradle.properties @@ -1,8 +1,9 @@ -# Minecraft 1.21.5 on NeoForge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 1.21.5 on NeoForge loader=neoforge minecraft_version=1.21.5 minecraft_version_range=[1.21.5,1.21.6) neoforge_version=21.5.101 loader_version_range=[21.5,) java_version=21 -scaffold=true +# NeoForge 1.21.5 pins this version strictly. +jspecify_version=1.0.0 diff --git a/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java index fdc41c77..a1a02c70 100644 --- a/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java +++ b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -1,19 +1,93 @@ package com.jnhprojects.mcheli.neoforge; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Placeholder entry point for Minecraft 1.21.5. It only proves that the module builds and loads; - * the NeoForge glue from neoforge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + 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(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); + 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())); + } - public McheliNeoForge() { - LOGGER.info("MCHeli Rework placeholder loaded on NeoForge for Minecraft 1.21.5; no content yet"); + 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.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 00000000..dd195bf7 --- /dev/null +++ b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,76 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.neoforge.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Minecraft; +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.RegisterMenuScreensEvent; +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.event.ViewportEvent; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov((float) event.getFOV()))); + 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-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 00000000..071dffd0 --- /dev/null +++ b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java @@ -0,0 +1,70 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +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; +import java.util.Set; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return new BlockEntityType<>(factory::apply, Set.of(block)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 00000000..195be00a --- /dev/null +++ b/neoforge-1.21.5/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-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java new file mode 100644 index 00000000..4c289d07 --- /dev/null +++ b/neoforge-1.21.5/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.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..b8646ada --- /dev/null +++ b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 1.21.5. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(ResourceLocation effect); +} diff --git a/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java b/neoforge-1.21.5/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java new file mode 100644 index 00000000..2fbe5f3e --- /dev/null +++ b/neoforge-1.21.5/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.5: 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.5/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-1.21.5/src/main/resources/META-INF/neoforge.mods.toml index 2585f0b9..c8095914 100644 --- a/neoforge-1.21.5/src/main/resources/META-INF/neoforge.mods.toml +++ b/neoforge-1.21.5/src/main/resources/META-INF/neoforge.mods.toml @@ -8,17 +8,19 @@ modId="${mod_id}" version="${mod_version}" displayName="${mod_name}" displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" -logoFile="mcheli_logo.png" +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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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" diff --git a/neoforge-1.21.5/src/main/resources/mcheli.mixins.json b/neoforge-1.21.5/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..77522ff3 --- /dev/null +++ b/neoforge-1.21.5/src/main/resources/mcheli.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraInvoker", + "GameRendererInvoker", + "GameRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/neoforge-26.1/build.gradle b/neoforge-26.1/build.gradle index 6dded114..60829c34 100644 --- a/neoforge-26.1/build.gradle +++ b/neoforge-26.1/build.gradle @@ -7,12 +7,17 @@ plugins { 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() diff --git a/neoforge-26.1/gradle.properties b/neoforge-26.1/gradle.properties index 265dfcf9..cbf34ea0 100644 --- a/neoforge-26.1/gradle.properties +++ b/neoforge-26.1/gradle.properties @@ -1,8 +1,9 @@ -# Minecraft 26.1 on NeoForge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 26.1 on NeoForge loader=neoforge minecraft_version=26.1 minecraft_version_range=[26.1,26.2) neoforge_version=26.1.2.126 loader_version_range=[26.1,) java_version=25 -scaffold=true +# NeoForge 26.1 pins this version strictly. +jspecify_version=1.0.0 diff --git a/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java index fa74df62..b5bd9c45 100644 --- a/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java +++ b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -1,19 +1,93 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Placeholder entry point for Minecraft 26.1. It only proves that the module builds and loads; - * the NeoForge glue from neoforge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + 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(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); + 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())); + } - public McheliNeoForge() { - LOGGER.info("MCHeli Rework placeholder loaded on NeoForge for Minecraft 26.1; no content yet"); + 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.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 00000000..9c8d7d61 --- /dev/null +++ b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.neoforge.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Minecraft; +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.RegisterMenuScreensEvent; +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.event.ViewportEvent; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov(event.getFOV()))); + 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.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 00000000..920adbdb --- /dev/null +++ b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.platform.Platform; +import net.minecraft.core.BlockPos; +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.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +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; +import java.util.Set; +import java.util.function.BiFunction; + +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 BlockEntityType blockEntityType(BiFunction factory, Block block) { + return new BlockEntityType<>(factory::apply, Set.of(block)); + } + + @Override + public CreativeModeTab.Builder creativeTab(ResourceKey after) { + return CreativeModeTab.builder().withTabsBefore(after); + } +} diff --git a/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 00000000..195be00a --- /dev/null +++ b/neoforge-26.1/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.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java new file mode 100644 index 00000000..4c289d07 --- /dev/null +++ b/neoforge-26.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-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..06a53c9a --- /dev/null +++ b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 26.1. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(Identifier effect); +} diff --git a/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java b/neoforge-26.1/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java new file mode 100644 index 00000000..073f2c37 --- /dev/null +++ b/neoforge-26.1/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, boolean advanceGameTime, CallbackInfo ci) { + ClientHooks.computeCameraAngles(mainCamera, deltaTracker.getGameTimeDeltaPartialTick(true), GameRendererMixin::setRotation); + } + + @Inject(method = "extractCamera", at = @At("HEAD")) + private void mcheli$beforeExtractCamera(DeltaTracker deltaTracker, float worldPartialTicks, float cameraEntityPartialTicks, 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.1/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-26.1/src/main/resources/META-INF/neoforge.mods.toml index 2585f0b9..c8095914 100644 --- a/neoforge-26.1/src/main/resources/META-INF/neoforge.mods.toml +++ b/neoforge-26.1/src/main/resources/META-INF/neoforge.mods.toml @@ -8,17 +8,19 @@ modId="${mod_id}" version="${mod_version}" displayName="${mod_name}" displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" -logoFile="mcheli_logo.png" +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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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" diff --git a/neoforge-26.1/src/main/resources/mcheli.mixins.json b/neoforge-26.1/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..77522ff3 --- /dev/null +++ b/neoforge-26.1/src/main/resources/mcheli.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraInvoker", + "GameRendererInvoker", + "GameRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/neoforge-26.2/build.gradle b/neoforge-26.2/build.gradle index 6dded114..60829c34 100644 --- a/neoforge-26.2/build.gradle +++ b/neoforge-26.2/build.gradle @@ -7,12 +7,17 @@ plugins { 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() diff --git a/neoforge-26.2/gradle.properties b/neoforge-26.2/gradle.properties index 2af5d6a3..fe795dc7 100644 --- a/neoforge-26.2/gradle.properties +++ b/neoforge-26.2/gradle.properties @@ -1,8 +1,9 @@ -# Minecraft 26.2 on NeoForge (placeholder until the port, see docs/DEVELOPMENT.md) +# Minecraft 26.2 on NeoForge loader=neoforge minecraft_version=26.2 minecraft_version_range=[26.2,26.3) neoforge_version=26.2.0.93 loader_version_range=[26.2,) java_version=25 -scaffold=true +# NeoForge 26.2 pins this version strictly. +jspecify_version=1.0.0 diff --git a/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java index 1f3a01b0..b5bd9c45 100644 --- a/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java +++ b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/McheliNeoForge.java @@ -1,19 +1,93 @@ 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Placeholder entry point for Minecraft 26.2. It only proves that the module builds and loads; - * the NeoForge glue from neoforge-26.3 replaces it when this version is ported for a final release. - */ -@Mod("mcheli") +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +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 { - private static final Logger LOGGER = LoggerFactory.getLogger("MCHeli"); + 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(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); + 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())); + } - public McheliNeoForge() { - LOGGER.info("MCHeli Rework placeholder loaded on NeoForge for Minecraft 26.2; no content yet"); + 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.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java new file mode 100644 index 00000000..9c8d7d61 --- /dev/null +++ b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeClient.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.neoforge; + +import com.jnhprojects.mcheli.client.ClientHooks; +import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; +import com.jnhprojects.mcheli.neoforge.mixin.GameRendererInvoker; +import com.jnhprojects.mcheli.registry.ModParticles; +import net.minecraft.client.Minecraft; +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.RegisterMenuScreensEvent; +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.event.ViewportEvent; +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(effect -> ((GameRendererInvoker) Minecraft.getInstance().gameRenderer).mcheli$setPostEffect(effect)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); + modBus.addListener(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov(event.getFOV()))); + 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.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgePlatform.java new file mode 100644 index 00000000..6cd0660a --- /dev/null +++ b/neoforge-26.2/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.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/NeoForgeVehicleIngredient.java new file mode 100644 index 00000000..195be00a --- /dev/null +++ b/neoforge-26.2/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.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/CameraInvoker.java new file mode 100644 index 00000000..4c289d07 --- /dev/null +++ b/neoforge-26.2/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.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java new file mode 100644 index 00000000..43944898 --- /dev/null +++ b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererInvoker.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.neoforge.mixin; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** Vanilla keeps {@code GameRenderer#setPostEffect} private on 26.2. */ +@Mixin(GameRenderer.class) +public interface GameRendererInvoker { + @Invoker("setPostEffect") + void mcheli$setPostEffect(Identifier effect); +} diff --git a/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java b/neoforge-26.2/src/main/java/com/jnhprojects/mcheli/neoforge/mixin/GameRendererMixin.java new file mode 100644 index 00000000..cac7a0b2 --- /dev/null +++ b/neoforge-26.2/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, float cameraEntityPartialTicks, 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.2/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-26.2/src/main/resources/META-INF/neoforge.mods.toml index 2585f0b9..c8095914 100644 --- a/neoforge-26.2/src/main/resources/META-INF/neoforge.mods.toml +++ b/neoforge-26.2/src/main/resources/META-INF/neoforge.mods.toml @@ -8,17 +8,19 @@ modId="${mod_id}" version="${mod_version}" displayName="${mod_name}" displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" -logoFile="mcheli_logo.png" +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. -This build is a placeholder: the port to this Minecraft version is not done yet. 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" diff --git a/neoforge-26.2/src/main/resources/mcheli.mixins.json b/neoforge-26.2/src/main/resources/mcheli.mixins.json new file mode 100644 index 00000000..77522ff3 --- /dev/null +++ b/neoforge-26.2/src/main/resources/mcheli.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.jnhprojects.mcheli.neoforge.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraInvoker", + "GameRendererInvoker", + "GameRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} 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 index 734b0ed6..b5bd9c45 100644 --- 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 @@ -12,6 +12,7 @@ import net.minecraft.server.level.ServerPlayer; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.AddPackFindersEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent; @@ -40,6 +41,7 @@ public McheliNeoForge(IEventBus modBus) { helper -> helper.register(VehicleIngredient.ID, NeoForgeVehicleIngredient.TYPE)); }); modBus.addListener(RegisterPayloadHandlersEvent.class, McheliNeoForge::registerPackets); + modBus.addListener(FMLCommonSetupEvent.class, event -> event.enqueueWork(CommonHooks::commonSetup)); modBus.addListener(AddPackFindersEvent.class, event -> event.addRepositorySource(CommonHooks.packSource())); NeoForge.EVENT_BUS.addListener(ServerAboutToStartEvent.class, event -> CommonHooks.serverAboutToStart(event.getServer())); 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 index 1cd86980..e8b91cc1 100644 --- 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 @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.client.ClientHooks; import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.registry.ModParticles; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; @@ -11,9 +12,11 @@ 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.RegisterMenuScreensEvent; 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.event.ViewportEvent; import net.neoforged.neoforge.client.gui.VanillaGuiLayers; import net.neoforged.neoforge.common.NeoForge; @@ -30,14 +33,20 @@ 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(EntityRenderersEvent.RegisterRenderers.class, + event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + modBus.addListener(RegisterMenuScreensEvent.class, event -> ClientHooks.registerMenuScreens(event::register)); + modBus.addListener(RegisterParticleProvidersEvent.class, event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.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(ViewportEvent.ComputeFov.class, event -> event.setFOV(ClientHooks.fov(event.getFOV()))); 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()));