diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a13537..320360f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ All notable changes to this project are documented here. The format follows [Semantic Versioning](https://semver.org/). Pre-releases (`1.0.0-alpha.N`) mark the phases on the way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). +## [1.1.0-beta.4] - 2026-10-30 + +Phase 8 of the road to 1.1.0: fuel, the first equipment carried over from phase 4, hit boxes that +cover the whole vehicle, and crash spins. Betas are not uploaded to CurseForge. From this beta on, +betas ship one jar, Forge for Minecraft 26.3; the other loaders and 1.21.1 come back for 1.1.0. +Worlds from 1.1.0-beta.3 load as before. Players and servers need the same version, since the +network protocol changed. + +### Added + +- Fuel. Vehicles with a `MaxFuel` burn their `FuelConsumption` while the throttle is open, and the + engine winds down when the tank runs dry. The HUD's `fuel` and `low_fuel` values are live, so the + original fuel gauges and low fuel warnings work. Fuel trucks top up every vehicle within their + `FuelSupplyRange`. Server config `vehicles.infiniteFuel` turns fuel use off. +- The fuel can (`mcheli:fuel`): right-click a vehicle to pour in up to 100 units, right-click in + the air with coal or charcoal in the inventory to fill it again. The original poured it through + the vehicle's inventory screen, which is not ported yet. +- The wrench (`mcheli:wrench`): right-click a vehicle to repair 10 hit points, once a second; with + Shift it switches the vehicle to its next paint scheme. +- The parachute (`mcheli:parachute`): use it in mid-air to float down, hold W to glide forward. +- An Equipment creative tab for the three, and the original's crafting recipes for them. Their + icons and names come from the imported content, like the vehicles'. +- Zoom: through a camera, a gunner sight, gunner mode or a UAV, Z doubles the zoom up to the + vehicle's `CameraZoom` and then goes back to 1x. Elsewhere Z still folds wings and hatches, as in + the original. The HUD's `cam_zoom` follows it. +- Night vision on K for vehicles with `EnableNightVision`. The HUD's `cam_mode` follows it. +- Vehicles collide with each other by their whole hull (the `BoundingBox` lines), not just the + small box at their feet: a vehicle cannot drive or fly into another one, a helicopter can set + down on top of a parked truck, and ramming uses the same boxes for crash damage. Vehicles that + can be loaded onto each other's racks still pass through, so they can drive onto the rack. +- Hit boxes for the parts a vehicle's `BoundingBox` lines leave out, such as the MD-90's tail fin, + made from its model when the content loads: every one-block cell of the model's surface that no + listed box covers becomes part of the hull. Rotor blades and propellers are left out. Vehicles + without any `BoundingBox` line (the MH-6, the R44 and others) get a hull this way too. +- With F3+B on, the hull boxes are outlined next to the main box: the definition's in cyan, the + generated ones in amber. +- Heavy aircraft (`MaxFuel` of 3000 or more: the MD-90, C-5, E-767, B-29, B-2A, AC-130, US-2 and + H8K) go up in three blasts like a destroyed boat, when wrecked on the ground or when their wreck + crashes. Only a hull in the water sinks afterwards. +- Crash spin: a shot down plane or jet rolls into a nose-down spiral, and a helicopter spins up + faster than before with some wobble, until it is close to the ground. The direction comes from + the entity, so everyone sees the same spin. + +### Changed + +- Ejection seats come down on a parachute instead of with slow falling. +- Vehicles are placed with a full tank (the original placed them empty, to be filled from the + inventory screen), and vehicles saved by earlier versions load with a full tank. +- The Z key is called "Zoom / Fold Wings / Hatch" in the controls. + +### Fixed + +- Rounds no longer seem to fly through a vehicle they hit. The server always counted the hit on + the hull, but the client kept drawing the round until the server removed it; it now stops and + disappears where it struck. Clients also learn who fired, so a round never stops on its own + vehicle. +- With an Iris shader pack, tracers, rocket motors, flares and explosion glows no longer turn into + long purple and teal lines running to the horizon. The glow pipelines are now handed to Iris, + which draws them with its emissive entity and translucent particle programs. + ## [1.1.0-beta.3] - 2026-10-30 Phase 7 of the road to 1.1.0: the mod on NeoForge and Fabric as well as Forge, and on Minecraft @@ -455,6 +515,8 @@ 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.4]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.4 +[1.1.0-beta.3]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.3 [1.1.0-beta.2]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.2 [1.1.0-beta.1]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.1 [1.0.0]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 48f853d..e71ec52 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,18 @@ Phase 4 clips (click a picture to open the video): ## Screenshots +Phase 8 in a development build (`1.1.0-beta.4`): hit boxes that cover the whole vehicle, and shot +down planes that spiral in. + +| | | +|---|---| +| ![The MD-90 with F3+B on: cyan boxes from its definition along the fuselage and wings, amber boxes from its model filling the nose, fin and tail](docs/images/phase-8/md-90-hull-boxes.png) | ![An EC665 Tiger seen from above with F3+B on, its hull covered in amber and cyan boxes and none in the rotor disc](docs/images/phase-8/ec665-hull-boxes.png) | +| The MD-90's hull: the definition's boxes (cyan) and the ones made from its model (amber) | The EC665's hull; the rotor disc stays clear | +| ![The S-75 sight with an SA-2 selected, locked onto a plane overhead](docs/images/phase-8/sa-2-lock-on-plane.png) | ![The SA-2 bursting on the plane in a cloud of smoke and debris](docs/images/phase-8/sa-2-hits-plane.png) | +| An S-75 locks onto a plane | The SA-2 bursts on it | +| ![Burning debris trailing down from the sky behind the plane as it spirals towards the ground](docs/images/phase-8/plane-crash-spin.png) | ![The wreck exploding on the ground in a desert village, debris arcing out](docs/images/phase-8/plane-crash-site.png) | +| The plane rolls into a nose-down spiral | It goes up where it comes down | + Phase 6 in a development build (`1.1.0-beta.2`): night glow. Tracers, rocket motors, flares and explosions light up the dark, and a wreck keeps burning until it disappears. @@ -163,7 +175,7 @@ and configs are the same files on all three. Do not open a 26.3 world in 1.21.1. ## Controls -Take a vehicle from one of the four MCHeli creative tabs (helicopters, planes, tanks and cars, +Take a vehicle from one of the MCHeli creative tabs (helicopters, planes, tanks and cars, ground vehicles), right-click a block to place it, and right-click anywhere on the vehicle to board. The first player aboard is the pilot; a closed canopy opens on the first click and closes once the pilot is in. Every key below can be rebound under Controls, category MCHeli. @@ -193,7 +205,7 @@ to climb. | W / S | Throttle up / down; full throttle is needed to climb | | A / D | Roll in the air, steer on the ground | | B | Landing gear up / down (it also comes down by itself near the ground) | -| Z | Fold or sweep the wings, or open the hatch | +| Z | Fold or sweep the wings, or open the hatch. Through a sight, zoom instead. | | V | VTOL mode, for planes that have it | | Space, twice | Eject, for planes with ejection seats | @@ -215,6 +227,8 @@ to climb. | R | Move to the next free seat | | J | Load nearby vehicles onto your racks, or drive onto a carrier's rack | | U | Unload the last vehicle from your racks, or leave the carrier | +| Z | Through a camera, gunner sight or gunner mode: zoom (2x, 4x and so on up to the vehicle's limit, then back) | +| K | Night vision on and off, in vehicles that have it | | Shift | Leave the vehicle | **Weapons** @@ -231,6 +245,17 @@ Each seat fires the weapons its vehicle assigns to it; the pilot also fires a gu while that seat has no player. Creative players have unlimited ammunition. A small readout at the bottom right shows hit points, the weapon, ammunition, reload, heat, lock and flares. +**Fuel and equipment** + +Vehicles burn fuel while the throttle is open; the HUD shows the tank and warns below 10%. With +an empty tank the engine winds down. Refuel with the **fuel can** from the Equipment tab: +right-click a vehicle to pour in up to 100 units, and right-click in the air with coal or +charcoal in your inventory to fill the can again. A fuel truck tops up every vehicle around it. +The **wrench** repairs 10 hit points per click, once a second; with Shift it switches the +vehicle's paint. The **parachute** opens when used in mid-air; hold W to glide forward. +Ejection seats come down on a parachute of their own. All three are crafted with the original's +recipes. + ## Commands and server settings | Command | Who | What it does | @@ -250,6 +275,7 @@ server runs): | `explosions.startFires` | `true` | Incendiary weapons and crashes start fires | | `vehicles.damageFactor` | `1.0` | Multiplies the damage vehicles take; `0.5` makes them twice as tough | | `vehicles.disabled` | `[]` | Vehicles that cannot be placed, by name, such as `["ah-64", "f-35b"]` | +| `vehicles.infiniteFuel` | `false` | Vehicles never run out of fuel | | `weapons.infiniteAmmo` | `false` | Vehicle weapons never run out of ammunition | | `packs.allowList` | `[]` | Content packs the server loads, by id; empty loads all of them | | `packs.maxPackSize` | `8` | Largest content pack the server loads and sends to players, in MB | diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java index d42a4d1..0ca7c32 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java @@ -8,11 +8,14 @@ import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.content.pack.PackProblem; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.model.HullFill; +import com.jnhprojects.mcheli.model.MeshLoader; 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.ArrayList; @@ -91,6 +94,9 @@ public static ContentRegistry load(List sources) { /** Collects definitions across sources; one per {@link #load(List)} call. */ private static final class Loader { + /** The client tries the same formats in the same order. */ + private static final String[] MODEL_EXTENSIONS = {".mqo", ".obj"}; + private final Map weapons = new LinkedHashMap<>(); private final Map vehicles = new LinkedHashMap<>(); private final Map> unhandled = new TreeMap<>(); @@ -135,11 +141,35 @@ void folder(ContentSource source, String folder, Function if (result.isValid()) { T def = result.definition(); def.setPack(source.pack()); + if (def instanceof AircraftDefinition vehicle) { + fillHull(source, vehicle); + } sink.accept(def); } } } + /** + * Reads the vehicle's model from the same source, as the client will, and fills the gaps + * its {@code BoundingBox} lines leave (see {@link HullFill}). Server and client build the + * same boxes from the same file, so hits agree. A missing or broken model only means no + * extra boxes; the client reports it when it draws the vehicle. + */ + private static void fillHull(ContentSource source, AircraftDefinition def) { + for (String extension : MODEL_EXTENSIONS) { + Path file = source.assets().resolve("models").resolve(def.folder()).resolve(def.name() + extension); + if (!Files.isRegularFile(file)) { + continue; + } + try (InputStream in = Files.newInputStream(file)) { + def.setGeneratedBoxes(HullFill.fill(MeshLoader.load(file.getFileName().toString(), in).groups(), def.boundingBoxes)); + } catch (IOException | RuntimeException e) { + LOGGER.debug("{}: no hull boxes from {}: {}", source.pack(), file, e.toString()); + } + return; + } + } + void weapon(WeaponDefinition weapon) { replaced(weapons.put(weapon.name(), weapon), weapon, WeaponDefinition.FOLDER); } diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java index f42654c..1404d13 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java @@ -13,6 +13,7 @@ import com.jnhprojects.mcheli.content.aircraft.part.WeaponBayPart; import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; import com.jnhprojects.mcheli.content.aircraft.part.WheelPart; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.Collections; @@ -86,6 +87,13 @@ public abstract class AircraftDefinition extends Definition { public boolean canRotateOnGround = true; public boolean enableBack; public final List boundingBoxes = new ArrayList<>(); + /** + * Not from the file: boxes for the parts of the model the {@code BoundingBox} lines leave out, + * made by {@link com.jnhprojects.mcheli.model.HullFill} when the content loads. Empty without a + * model. + */ + private List generatedBoxes = List.of(); + private @Nullable List hull; public float markerWidth = 2.0F; public float markerHeight = 1.0F; @@ -191,6 +199,27 @@ protected AircraftDefinition(String name) { /** Asset folder for this kind, for example {@code helicopters}. */ public abstract String folder(); + public List generatedBoxes() { + return generatedBoxes; + } + + public void setGeneratedBoxes(List boxes) { + generatedBoxes = List.copyOf(boxes); + hull = null; + } + + /** Every hit box besides the main one: the {@code BoundingBox} lines, then the generated boxes. */ + public List hull() { + List h = hull; + if (h == null) { + List all = new ArrayList<>(boundingBoxes); + all.addAll(generatedBoxes); + h = List.copyOf(all); + hull = h; + } + return h; + } + /** HUD used for a seat that the definition did not configure. */ protected abstract String defaultHud(int seatIndex); diff --git a/common/src/main/java/com/jnhprojects/mcheli/model/HullFill.java b/common/src/main/java/com/jnhprojects/mcheli/model/HullFill.java new file mode 100644 index 0000000..16dd061 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/model/HullFill.java @@ -0,0 +1,138 @@ +package com.jnhprojects.mcheli.model; + +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.BoundingBoxDefinition; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Not in the original: hit boxes for the parts of a model that its {@code BoundingBox} lines leave + * out, such as the MD-90's tail fin. The model's surface is cut into cubes of one cell; every cell + * a triangle passes through and no listed box already covers becomes part of the hull, and cells + * stacked on top of each other merge into one column. + * + *

Columns stay as wide as one cell because hull boxes do not turn with the vehicle, only their + * centres do: a long box would lie across the hull once the vehicle turned. Rotor blades and + * propellers spin through a whole disc, so their parts are left out. + */ +public final class HullFill { + /** The size of a cell, in blocks. */ + public static final double CELL = 1.0; + /** A safety limit; a model this detailed gets no generated boxes rather than thousands. */ + static final int MAX_BOXES = 1500; + + private HullFill() { + } + + /** + * @param groups the model, every group in file order + * @param listed the boxes the definition already has, which the fill does not repeat + * @return boxes for the uncovered surface, in hull space, with a damage factor of 1 + */ + public static List fill(List groups, List listed) { + Set cells = new HashSet<>(); + String part = ""; + for (MeshGroup group : groups) { + if (group.isPartHeader()) { + part = group.name().toLowerCase(Locale.ROOT); + } + if (!spins(part)) { + surface(group, cells); + } + } + cells.removeIf(cell -> covered(cell, listed)); + List boxes = columns(cells); + return boxes.size() > MAX_BOXES ? List.of() : boxes; + } + + /** Parts that turn through a full circle: helicopter rotors and plane propellers. */ + static boolean spins(String part) { + return part.startsWith("$blade") || part.startsWith("$rotor") || part.startsWith("$rotpart"); + } + + /** + * Marks every cell a triangle of {@code group} touches, by sampling each triangle at less than + * half a cell apart. + */ + private static void surface(MeshGroup group, Set cells) { + float[] p = group.positions(); + for (int i = 0; i + 8 < p.length; i += 9) { + double ax = p[i]; + double ay = p[i + 1]; + double az = p[i + 2]; + double bx = p[i + 3] - ax; + double by = p[i + 4] - ay; + double bz = p[i + 5] - az; + double cx = p[i + 6] - ax; + double cy = p[i + 7] - ay; + double cz = p[i + 8] - az; + double longest = Math.max(Math.sqrt(bx * bx + by * by + bz * bz), Math.sqrt(cx * cx + cy * cy + cz * cz)); + int steps = Math.max(1, (int) Math.ceil(longest / (CELL * 0.4))); + for (int s = 0; s <= steps; s++) { + for (int t = 0; s + t <= steps; t++) { + double u = (double) s / steps; + double v = (double) t / steps; + cells.add(Cell.at(ax + bx * u + cx * v, ay + by * u + cy * v, az + bz * u + cz * v)); + } + } + } + } + + /** Whether a listed box already holds the cell's centre. */ + private static boolean covered(Cell cell, List listed) { + double x = (cell.x + 0.5) * CELL; + double y = (cell.y + 0.5) * CELL; + double z = (cell.z + 0.5) * CELL; + for (BoundingBoxDefinition box : listed) { + Vector3 c = box.offset(); + double hw = box.width() / 2.0; + double hh = box.height() / 2.0; + if (Math.abs(x - c.x()) <= hw && Math.abs(y - c.y()) <= hh && Math.abs(z - c.z()) <= hw) { + return true; + } + } + return false; + } + + /** Stacks of cells in the same column become one box each. */ + private static List columns(Set cells) { + TreeMap> byColumn = new TreeMap<>(); + for (Cell cell : cells) { + byColumn.computeIfAbsent(((long) cell.x << 32) | (cell.z & 0xFFFFFFFFL), k -> new TreeSet<>()).add(cell.y); + } + List boxes = new ArrayList<>(); + byColumn.forEach((key, ys) -> { + int x = (int) (key >> 32); + int z = (int) (long) key; + int start = ys.first(); + int previous = start; + for (int y : ys.tailSet(start, false)) { + if (y != previous + 1) { + boxes.add(column(x, z, start, previous)); + start = y; + } + previous = y; + } + boxes.add(column(x, z, start, previous)); + }); + return boxes; + } + + private static BoundingBoxDefinition column(int x, int z, int bottom, int top) { + double height = (top - bottom + 1) * CELL; + Vector3 centre = new Vector3((x + 0.5) * CELL, bottom * CELL + height / 2.0, (z + 0.5) * CELL); + return new BoundingBoxDefinition(centre, (float) CELL, (float) height, 1.0F); + } + + private record Cell(int x, int y, int z) { + static Cell at(double x, double y, double z) { + return new Cell((int) Math.floor(x / CELL), (int) Math.floor(y / CELL), (int) Math.floor(z / CELL)); + } + } +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/physics/CrashSpin.java b/common/src/main/java/com/jnhprojects/mcheli/physics/CrashSpin.java new file mode 100644 index 0000000..3abbc6e --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/physics/CrashSpin.java @@ -0,0 +1,72 @@ +package com.jnhprojects.mcheli.physics; + +/** + * Not in the original: how a shot down aircraft goes in. A plane or jet rolls into a nose-down + * spiral, and a helicopter that lost its tail rotor spins up with some wobble. The original only + * nudged pitch and roll by a random amount picked on the server. The direction here comes from + * the entity id, so the server and every client agree on it without syncing anything. + * + *

All rates are in degrees per tick; {@code ticks} counts from the moment of destruction. + */ +public final class CrashSpin { + /** How far the nose drops in a plane's spiral. */ + public static final float PLANE_DIVE_PITCH = 55.0F; + private static final float PLANE_PITCH_RATE = 1.2F; + private static final float PLANE_MAX_ROLL_RATE = 9.0F; + private static final float PLANE_YAW_RATE = 2.0F; + + private static final float HELICOPTER_MAX_YAW_RATE = 24.0F; + private static final float HELICOPTER_WOBBLE_PITCH = 7.0F; + private static final float HELICOPTER_WOBBLE_ROLL = 10.0F; + + private CrashSpin() { + } + + /** 1 for a spin to the right, -1 to the left; the same for an entity id everywhere. */ + public static int direction(int entityId) { + // Mix the bits so neighbouring ids, spawned one after the other, do not all spin alike. + int h = entityId * 0x9E3779B9; + h ^= h >>> 16; + return (h & 1) == 0 ? 1 : -1; + } + + /** A plane's roll rate: it builds up over the first two seconds of the fall. */ + public static float planeRollRate(int ticks, int direction) { + return direction * Math.min(1.0F + ticks * 0.2F, PLANE_MAX_ROLL_RATE); + } + + /** A plane's yaw rate, turning the roll into a spiral rather than a barrel roll in place. */ + public static float planeYawRate(int ticks, int direction) { + return direction * Math.min(ticks * 0.05F, PLANE_YAW_RATE); + } + + /** The plane's next pitch, easing towards the dive. Positive pitch is nose down. */ + public static float planePitch(float pitch) { + if (pitch < PLANE_DIVE_PITCH) { + return Math.min(pitch + PLANE_PITCH_RATE, PLANE_DIVE_PITCH); + } + return Math.max(pitch - PLANE_PITCH_RATE, PLANE_DIVE_PITCH); + } + + /** A helicopter's yaw rate: faster than the original's 15, reached in about two seconds. */ + public static float helicopterYawRate(int ticks, int direction) { + return direction * Math.min(2.0F + ticks * 0.6F, HELICOPTER_MAX_YAW_RATE); + } + + /** + * The pitch the helicopter wobbles around at this tick. The phase comes from the id, so two + * helicopters going down together do not nod in step. + */ + public static float helicopterWobblePitch(int ticks, int entityId) { + return HELICOPTER_WOBBLE_PITCH * (float) Math.sin(ticks * 0.35 + phase(entityId)); + } + + /** The roll the helicopter wobbles around at this tick. */ + public static float helicopterWobbleRoll(int ticks, int entityId) { + return HELICOPTER_WOBBLE_ROLL * (float) Math.cos(ticks * 0.27 + phase(entityId)); + } + + private static double phase(int entityId) { + return (entityId & 0xFF) * (Math.PI * 2.0 / 256.0); + } +} diff --git a/common/src/main/resources/assets/mcheli/lang/en_us.json b/common/src/main/resources/assets/mcheli/lang/en_us.json index 5155990..79dac13 100644 --- a/common/src/main/resources/assets/mcheli/lang/en_us.json +++ b/common/src/main/resources/assets/mcheli/lang/en_us.json @@ -21,6 +21,7 @@ "entity.mcheli.flare": "Flare", "entity.mcheli.helicopter": "Helicopter", "entity.mcheli.marker_rocket": "Marker Rocket", + "entity.mcheli.parachute": "Parachute", "entity.mcheli.plane": "Plane", "entity.mcheli.rocket": "Rocket", "entity.mcheli.tank": "Tank", @@ -45,13 +46,15 @@ "item.mcheli.plane": "Plane", "item.mcheli.tank": "Tank", "item.mcheli.vehicle": "Ground Vehicle", + "itemGroup.mcheli.equipment": "MCHeli Equipment", "itemGroup.mcheli.helicopters": "MCHeli Helicopters", "itemGroup.mcheli.planes": "MCHeli Planes", "itemGroup.mcheli.tanks": "MCHeli Tanks and Cars", "itemGroup.mcheli.vehicles": "MCHeli Ground Vehicles", "key.category.mcheli.main": "MCHeli", + "key.mcheli.camera_mode": "Night Vision", "key.mcheli.flare": "Flares", - "key.mcheli.fold": "Fold Wings / Hatch", + "key.mcheli.fold": "Zoom / Fold Wings / Hatch", "key.mcheli.free_look": "Free Look", "key.mcheli.landing_gear": "Landing Gear", "key.mcheli.rack_load": "Load Rack / Board Carrier", diff --git a/common/src/main/resources/assets/mcheli/lang/ja_jp.json b/common/src/main/resources/assets/mcheli/lang/ja_jp.json index 2ac248b..d3b946e 100644 --- a/common/src/main/resources/assets/mcheli/lang/ja_jp.json +++ b/common/src/main/resources/assets/mcheli/lang/ja_jp.json @@ -1,5 +1,6 @@ { "entity.mcheli.helicopter": "ヘリコプター", + "entity.mcheli.parachute": "パラシュート", "entity.mcheli.plane": "飛行機", "entity.mcheli.tank": "戦車", "entity.mcheli.vehicle": "地上兵器", @@ -7,12 +8,14 @@ "item.mcheli.plane": "飛行機", "item.mcheli.tank": "戦車", "item.mcheli.vehicle": "地上兵器", + "itemGroup.mcheli.equipment": "MCHeli 装備", "itemGroup.mcheli.helicopters": "MCHeli ヘリコプター", "itemGroup.mcheli.planes": "MCHeli 飛行機", "itemGroup.mcheli.tanks": "MCHeli 戦車・車両", "itemGroup.mcheli.vehicles": "MCHeli 地上兵器", "key.category.mcheli.main": "MCHeli", - "key.mcheli.fold": "翼・ハッチの折りたたみ", + "key.mcheli.camera_mode": "暗視", + "key.mcheli.fold": "ズーム / 翼・ハッチの折りたたみ", "key.mcheli.free_look": "フリールック", "key.mcheli.landing_gear": "着陸脚", "key.mcheli.rack_load": "ラックに積載 / 搭載", diff --git a/common/src/test/java/com/jnhprojects/mcheli/model/HullFillTest.java b/common/src/test/java/com/jnhprojects/mcheli/model/HullFillTest.java new file mode 100644 index 0000000..709e3e6 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/model/HullFillTest.java @@ -0,0 +1,75 @@ +package com.jnhprojects.mcheli.model; + +import com.jnhprojects.mcheli.TestResources; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.BoundingBoxDefinition; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HullFillTest { + /** A fin: a vertical square from y 4 to 8, two blocks long, in the plane x = 0.2. */ + private static MeshGroup fin(String name) { + float[] p = { + 0.2F, 4.0F, -50.0F, 0.2F, 8.0F, -50.0F, 0.2F, 8.0F, -48.0F, + 0.2F, 4.0F, -50.0F, 0.2F, 8.0F, -48.0F, 0.2F, 4.0F, -48.0F}; + return new MeshGroup(name, p, new float[p.length], new float[p.length / 3 * 2]); + } + + private static boolean covers(List boxes, double x, double y, double z) { + for (BoundingBoxDefinition box : boxes) { + Vector3 c = box.offset(); + if (Math.abs(x - c.x()) <= box.width() / 2.0 && Math.abs(y - c.y()) <= box.height() / 2.0 + && Math.abs(z - c.z()) <= box.width() / 2.0) { + return true; + } + } + return false; + } + + @Test + void coversAnUnlistedFinWithColumns() { + List boxes = HullFill.fill(List.of(fin("fin")), List.of()); + assertTrue(covers(boxes, 0.2, 6.0, -49.0)); + assertTrue(covers(boxes, 0.2, 7.9, -48.1)); + // Two or three cells along z, each one column tall. + assertTrue(boxes.size() <= 3, boxes::toString); + for (BoundingBoxDefinition box : boxes) { + assertEquals(HullFill.CELL, box.width(), 1.0E-6); + assertEquals(1.0F, box.damageFactor()); + } + } + + @Test + void leavesOutWhatTheDefinitionListsAndWhatSpins() { + BoundingBoxDefinition listed = new BoundingBoxDefinition(new Vector3(0.0, 6.0, -49.0), 4.0F, 6.0F, 0.5F); + assertTrue(HullFill.fill(List.of(fin("fin")), List.of(listed)).isEmpty()); + MeshGroup rotor = new MeshGroup("$rotor0", new float[0], new float[0], new float[0]); + assertTrue(HullFill.fill(List.of(rotor, fin("blade")), List.of()).isEmpty()); + assertFalse(HullFill.fill(List.of(rotor, new MeshGroup("$body", new float[0], new float[0], new float[0]), fin("hull")), List.of()).isEmpty()); + } + + @Test + void fillsTheMd90TailFin() { + ContentRegistry content = ContentRegistry.load(TestResources.assets()); + AircraftDefinition md90 = content.any("md90"); + assertNotNull(md90); + assertFalse(md90.generatedBoxes().isEmpty()); + // The fin stands between the fuselage (top at y 4.5) and the T-tail (y 8.5), around z -47. + boolean fin = false; + for (double y = 5.0; y <= 8.0 && !fin; y += 0.5) { + for (double z = -50.0; z <= -44.0 && !fin; z += 0.5) { + fin = covers(md90.generatedBoxes(), 0.0, y, z); + } + } + assertTrue(fin, "no generated box in the MD-90 fin"); + assertTrue(md90.hull().size() > md90.boundingBoxes.size()); + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/physics/CrashSpinTest.java b/common/src/test/java/com/jnhprojects/mcheli/physics/CrashSpinTest.java new file mode 100644 index 0000000..e7a1387 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/physics/CrashSpinTest.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli.physics; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CrashSpinTest { + @Test + void directionIsStableAndBothWaysOccur() { + Set seen = new HashSet<>(); + for (int id = 0; id < 64; id++) { + int direction = CrashSpin.direction(id); + assertEquals(direction, CrashSpin.direction(id)); + assertTrue(direction == 1 || direction == -1); + seen.add(direction); + } + assertEquals(Set.of(1, -1), seen); + } + + @Test + void planeRollBuildsUpAndLevelsOff() { + assertTrue(Math.abs(CrashSpin.planeRollRate(0, 1)) < Math.abs(CrashSpin.planeRollRate(20, 1))); + assertEquals(CrashSpin.planeRollRate(200, 1), CrashSpin.planeRollRate(400, 1)); + assertEquals(-CrashSpin.planeRollRate(30, 1), CrashSpin.planeRollRate(30, -1)); + } + + @Test + void planeNoseDropsToTheDiveAndStays() { + float pitch = -10.0F; + for (int i = 0; i < 200; i++) { + pitch = CrashSpin.planePitch(pitch); + } + assertEquals(CrashSpin.PLANE_DIVE_PITCH, pitch); + assertTrue(CrashSpin.planePitch(80.0F) < 80.0F); + } + + @Test + void helicopterSpinsFasterThanTheOriginal() { + assertTrue(Math.abs(CrashSpin.helicopterYawRate(100, 1)) > 15.0F); + assertTrue(Math.abs(CrashSpin.helicopterWobblePitch(5, 7)) <= 7.0F); + assertTrue(Math.abs(CrashSpin.helicopterWobbleRoll(5, 7)) <= 10.0F); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07a0159..7532a48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -444,6 +444,50 @@ or height between a harmless and a fatal value, before armour. A vehicle wrecked or a falling wreck that lands, goes up through `crashExplosion`: the fuel sized explosion of the original plus `Explosions.crashFireball`, which only draws a bigger fire. +On the way down, a shot down plane or helicopter goes through `crashSpin`, with the rates from +`physics.CrashSpin` in `common`: a plane eases its nose down to a 55 degree dive while its roll and +yaw rates build up, a helicopter spins up to 24 degrees a tick and wobbles in pitch and roll. The +direction and the wobble phase come from the entity id, so every side computes the same spin +without syncing anything; the server simulates the wreck and its rotation reaches clients as usual. + +### Hull collisions + +The `BoundingBox` lines are the hull, filled in where they leave gaps by `model.HullFill`: when the +content loads, `ContentRegistry` reads each vehicle's model from the same pack, cuts its surface +into one-block cells, drops the cells a listed box already holds and the spinning parts (`$blade`, +`$rotor`, `$rotpart`), and stacks the rest into one-cell columns (`AircraftDefinition.hull()` holds +both). Columns stay one cell wide because hull boxes only move their centres with the vehicle, +not their shape. Server and client read the same file, so they agree on hits. `hullBoxes()` +returns the hull in world space, axis aligned as in the original, next to the main box. Projectiles and boarding clicks clip against them +(`clipHitBoxes`), and so do other vehicles: `move` is overridden to undo a step that would push the +hull into another vehicle's, first sideways and then completely, which also lets a vehicle rest on +top of another. Vanilla entity collision only knows one box per entity, so this check runs on top +of it, on whichever side simulates the vehicle. Pairs that may load each other onto a rack, and a +carrier and its cargo, are left out. The server's ramming check uses the same boxes and, after a +sudden stop, looks one tick of travel ahead, because a blocked step on the pilot's client leaves +the hulls just short of touching. With F3+B on, `AircraftRenderer` outlines the hull through the +per-frame gizmo collector. + +A projectile hit only does something on the server. Clients still run the same hit test (they know +the shooter from synched data, so a round never stops on its own vehicle) and park the round, +hidden, where it struck until the server removes it. + +### Fuel and equipment + +Fuel lives in the synched `DATA_FUEL`, saved as `Fuel`. The server burns it once a second in +`burnFuel` (the original's `updateFuel`) and fuel trucks share it in `supplyFuel`; whichever side +simulates the vehicle reads `hasFuel()` and lets the throttle run down when the tank is empty, so +the pilot's client and the server agree without another packet. Vehicles saved before fuel +existed load full. + +The fuel can, wrench and parachute are plain items (`item.FuelItem`, `WrenchItem`, +`ParachuteItem`). Fuel and wrench act in `AircraftEntity.interact` before boarding, which also +covers clicks on the hull outside the main box (they arrive through `BoardVehiclePacket`). +`ParachuteEntity` is the original's `MCH_EntityParachute`: server simulated, ridden by the +player, steered by the rider's forward input from `ServerPlayer.getLastClientInput`. Its +`Kind` picks the model: the round canopy for the item, the seat for ejection (whose canopy opens +once it falls), the bare canopy for drops. + ## Client **CrewController** samples keys each client tick and the mouse every frame, for whichever seat the @@ -490,6 +534,18 @@ How bright a glow gets comes from `effect.NightGlow` in `common`: the block ligh the sky darkening at that spot, so it is faint at noon and full at night, in caves and under water. `ClientConfig` (`config/mcheli-client.toml`) turns it off per player. +Iris replaces every vanilla pipeline with its own shader programs and draws unknown ones with a +program whose vertex layout does not match. `IrisCompat` looks up `IrisApi` by reflection on the +first client tick (Iris is optional, so nothing is compiled against it) and assigns the halo +pipeline to `EMISSIVE_ENTITIES` and the particle pipeline to `PARTICLES_TRANSLUCENT`. + +**VehicleCamera** holds the local crew member's zoom and camera mode, the client half of the +original's `MCH_Camera`. Zoom narrows the field of view through `ViewportEvent.ComputeFov`; it is +offered on the fold key whenever the seat looks through a sight, as the original did. Night vision +asks the server through `CrewActionPacket`, and `AircraftEntity` keeps a short ambient night +vision effect topped up on that seat's occupant, removing it again on leaving (unless a longer +effect, such as a potion, was there). + **CrewController** also hides the first person hand through `RenderHandEvent` wherever the original did: for the pilot, and for anyone looking through a camera or with a weapon selected. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0a3cced..f8daa36 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -37,7 +37,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. +module has its own `gradle.properties` with its loader, Minecraft and Java versions. During betas +4 to 7, `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 1.1.0 (roadmap phase 12). `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`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6cb6f24..7ac4dfc 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-11 | `1.1.0-beta.1` to `beta.7` | Content packs, NeoForge and Fabric, 1.21.1 and 1.20.1, equipment, workshop ([plan](#road-to-110)) | In progress | +| 5-12 | `1.1.0-beta.1` to `beta.8` | Content packs, NeoForge and Fabric, 1.21.1, equipment, workshop, 1.20.1 ([plan](#road-to-110)) | In progress | | | `1.1.0` | Release on CurseForge | Planned | ## Phase 1: foundation and flyable helicopters @@ -84,7 +84,7 @@ textures (back faces are culled again). See the [test plan](TESTING.md#phase-4-c the recipe book from the first login - [x] Code-only jar: the original content is imported from the player's own copy of the 1.0.3 zip -The rest of the original phase 4 list moved to 1.1.0 (phase 9 below), so 1.0.0 can ship. +The rest of the original phase 4 list moved to 1.1.0 (phases 8 and 9 below), so 1.0.0 can ship. ## 1.0.0 release criteria @@ -97,7 +97,7 @@ The rest of the original phase 4 list moved to 1.1.0 (phase 9 below), so 1.0.0 c 1.1.0 adds three big things on top of 1.0.0: content packs, more Minecraft versions and loaders, and the vehicle workshop. It also picks up the phase 4 items that were left out of 1.0.0. The work is -split into seven phases, each ending in a beta. +split into eight phases, each ending in a beta. ### Release channels @@ -125,7 +125,7 @@ Rules that hold for every beta: - A world saved by 1.0.0 or an earlier beta loads in the next one. Breaking a save format needs a data fixer or a migration, and a line under `Changed` in the changelog. - The jar still holds no EMB4 file, on every Minecraft version. -- A phase is done when the build is green for every supported Minecraft version, its checklist +- A phase is done when the build is green for every module it builds, its checklist in [TESTING.md](TESTING.md) passes in single player and on a dedicated server, and `CHANGELOG.md`, the docs and [ARCHITECTURE.md](ARCHITECTURE.md) describe what changed. @@ -134,17 +134,20 @@ Rules that hold for every beta: | 5 | `1.1.0-beta.1` | Groundwork: runtime registry, config, multi-version build | Released | | 6 | `1.1.0-beta.2` | Content packs and multiplayer sync | Released | | 7 | `1.1.0-beta.3` | Loaders (Forge, NeoForge, Fabric) and 1.21.1 | Released | -| 8 | `1.1.0-beta.4` | 1.20.1 | Planned | -| 9 | `1.1.0-beta.5` | Equipment and the rest of phase 4 | Planned | -| 10 | `1.1.0-beta.6` | Workshop: model, parts and tuning | Planned | -| 11 | `1.1.0-beta.7` | Workshop: textures, sounds and sharing | Planned | +| 8 | `1.1.0-beta.4` | Fuel, first equipment, hull hit boxes (Forge 26.3 only) | Released | +| 9 | `1.1.0-beta.5` | The rest of the equipment (Forge 26.3 only) | Planned | +| 10 | `1.1.0-beta.6` | Workshop: model, parts and tuning (Forge 26.3 only) | Planned | +| 11 | `1.1.0-beta.7` | Workshop: textures, sounds and sharing (Forge 26.3 only) | Planned | +| 12 | `1.1.0-beta.8` | Every loader, 1.21.1 and 1.20.1 | Planned | | | `1.1.0` | Release on CurseForge | Planned | The order is deliberate. The registry and the source split come first because everything else sits on them. Content packs come before the workshop, since the workshop's output is a pack. -The ports come right after packs, while the code is still close to 1.0.0; from then on each -feature is built once in shared code and only its Minecraft glue is written per version and -loader, instead of back-porting several phases of work at the end. +The loader split and 1.21.1 come right after packs, while the code is still close to 1.0.0; +from then on each feature is built once in shared code and only its Minecraft glue is written per +version and loader. Betas 4 to 7 build only the Forge 26.3 jar, since six modules on three +toolchains made every build slow; phase 12 writes the glue for the rest, adds 1.20.1 and ships +every jar again. ### Supported versions @@ -225,31 +228,31 @@ almost everything, adds NeoForge and Fabric on 26.3, then brings all three to 1. - [x] [TESTING.md](TESTING.md) gets a short smoke test per jar: import, fly a helicopter, drive a tank, fire a missile, join a dedicated server -## Phase 8: 1.20.1 (`1.1.0-beta.4`) +## Phase 8: fuel, first equipment and hull hit boxes (`1.1.0-beta.4`) -The largest modpack version, and the furthest from 26.3. +Gameplay carried over from phase 4, built once in shared code with per-loader glue. Built and +tested on Forge 26.3 only; the other loaders and versions get it in phase 12. What was not done +in this phase moved to phase 9. -- [ ] `common` compiles for Java 17 (no pattern matching `switch`, no `SequencedCollection` - methods) -- [ ] 1.20.1 on Forge and Fabric. NeoForge's 1.20.1 is a fork of Forge 47, so the Forge jar is - tested there instead of built twice -- [ ] Vehicle items keep their vehicle in NBT, since data components only arrived in 1.20.5 -- [ ] Rendering, networking and registration for 1.20.1 -- [ ] Legacy importer and content packs checked on 1.20.1 -- [ ] Same smoke tests as phase 7, for every 1.20.1 jar - -## Phase 9: equipment and the rest of phase 4 (`1.1.0-beta.5`) - -Gameplay carried over from phase 4, built once in `common` with per-version glue. - -- [ ] Crash spin for shot down aircraft (moved from phase 6): planes and jets roll into a +- [x] Glows under Iris shaders: the custom glow pipelines are handed to Iris, so tracers, motors, + flares and explosions no longer streak to the horizon with a shader pack on +- [x] Crash spin for shot down aircraft (moved from phase 6): planes and jets roll into a nose-down spiral instead of just falling, and helicopters spin faster with some wobble. The spin direction comes from the entity id so every client sees the same thing. -- [ ] Night vision, thermal view and zoom -- [ ] Equipment: GLTD, rangefinder, UAV station and portable controller, parachute, chain towing, - container, fuel, wrench, Stinger and Javelin, smoke grenades -- [ ] Fuel use and the vehicle inventory -- [ ] Parachutes for ejection seats and rack drops, containers on racks, repelling and troop drops +- [x] Night vision and zoom +- [x] Fuel use, fuel trucks, the fuel can, the wrench and the parachute +- [x] Parachutes for ejection seats +- [x] Hit boxes over the whole vehicle: the definition's boxes filled in from the model, used by + projectiles and by collisions between vehicles +- [x] Heavy aircraft cook off in three blasts + +## Phase 9: the rest of the equipment (`1.1.0-beta.5`) + +- [ ] Thermal view +- [ ] The vehicle inventory, with its fuel slots +- [ ] Equipment: GLTD, rangefinder, UAV station and portable controller, chain towing, container, + Stinger and Javelin, smoke grenades +- [ ] Parachutes for rack drops, containers on racks, repelling and troop drops - [ ] `HideEntity`, search lights, splash and exhaust particles, helicopter blade folding - [ ] Resupply from the inventory screen and from supply vehicles, marking blocks with targeting pods, mortar distance @@ -277,9 +280,26 @@ built here syncs to other players with no extra work. - [ ] Package browser, author credits shown in game - [ ] Operator approval before a player's new vehicle goes live on a server +## Phase 12: every loader, 1.21.1 and 1.20.1 (`1.1.0-beta.8`) + +Betas 4 to 7 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 adds 1.20.1, the largest modpack version +and the furthest from 26.3. All jars ship again from here on. + +- [ ] Every module back in `platforms`; phases 8 to 11 ported to NeoForge and Fabric on 26.3 and + to all three loaders on 1.21.1 +- [ ] `common` compiles for Java 17 (no pattern matching `switch`, no `SequencedCollection` + methods) +- [ ] 1.20.1 on Forge and Fabric. NeoForge's 1.20.1 is a fork of Forge 47, so the Forge jar is + tested there instead of built twice +- [ ] Vehicle items keep their vehicle in NBT, since data components only arrived in 1.20.5 +- [ ] Rendering, networking and registration for 1.20.1 +- [ ] Legacy importer and content packs checked on 1.20.1 +- [ ] The phase 7 smoke tests for every jar + ## 1.1.0 release -No new features after beta.7; a `1.1.0-rc.N` follows only if beta.7 testing finds problems. +No new features after beta.8; a `1.1.0-rc.N` follows only if beta.8 testing finds problems. - [ ] Every phase checklist done on every supported Minecraft version - [ ] A 1.0.0 world and the beta worlds load with their vehicles and items intact diff --git a/docs/TESTING.md b/docs/TESTING.md index e760518..0f382db 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,7 @@ Run this for each jar in `build/release`: Forge, NeoForge and Fabric on every Mi - [ ] The game reaches the title screen. The log has `MCHeli Rework on ` and no `mcheli` errors; on the first start it also has `Imported ... files` from the zip. -- [ ] The four MCHeli creative tabs list the vehicles with icons and names, after the vanilla tabs. +- [ ] The four MCHeli vehicle tabs list the vehicles with icons and names, after the vanilla tabs. - [ ] Fly a helicopter (`ah-64`): it takes off, banks and lands. In first person the view rolls with the hull and the eye sits in the cockpit; the hand is hidden in the pilot seat. - [ ] Other players and the rider model tilt with the hull (second player or F5). @@ -405,6 +405,79 @@ Copy `examples/rework-example-pack` into `/mcheli/packs` first. - [ ] Remove the pack from the server and restart it: a world with a Scout still loads; the Scout stays in place, inert and invisible, and comes back when the pack does. +## Phase 8 checklist (`1.1.0-beta.4`) + +Forge 26.3 only. Start from a world made by beta.3 with a few vehicles in it. + +### Shaders + +Install Sodium and Iris for 26.3 with a shader pack (Complementary or BSL). + +- [ ] Fire tracers and rockets, release flares, blow something up, call in the A-10: the glows sit + where they belong, with no purple, pink or teal lines running to the horizon. +- [ ] The log has `Iris draws mcheli:pipeline/glow with its EMISSIVE_ENTITIES program` at debug + level (or no warning about Iris). Without Iris the game starts as before. +- [ ] Note whether the A-10 still draws as a flat white shape with the shader pack on. + +### Crash spin + +- [ ] Shoot down a plane (`f-35b`, `/mcheli give`, then a missile or `/kill`): it rolls into a + nose-down spiral and stops spinning near the ground. A second plane may spin the other way. +- [ ] Shoot down a helicopter: it spins up faster than in beta.3 and nods and rocks while it falls. +- [ ] A second player watching sees the same spin direction. +- [ ] A tank or car that is destroyed does not spin. + +### Fuel + +- [ ] A beta.3 vehicle loads with a full tank (helicopter HUD fuel gauge full). +- [ ] Fly with the throttle open: the gauge goes down; with `/data merge` or a long flight down to + 10% the low fuel warning blinks; at empty the throttle runs down and the engine stops. +- [ ] Right-click the vehicle with the fuel can: the tank fills by 100 and the can's bar drops. + Right-click in the air with coal in the inventory: the coal is used up and the can refills. +- [ ] Park next to a fuel truck: the tank fills and does not drop while the throttle is open. +- [ ] `vehicles.infiniteFuel = true` and `/mcheli reload`: the tank stops going down. +- [ ] A vehicle without `MaxFuel` (check the HUD) still flies forever. + +### Wrench and parachute + +- [ ] Damage a vehicle, right-click it with the wrench: 10 hit points back per click, once a + second, with the wrench sound; the wrench loses durability. A wreck cannot be repaired. +- [ ] Shift right-click a vehicle with several paints (`ah-64`) with the wrench: the paint changes + for everyone. +- [ ] Jump off a cliff and use the parachute: it opens above you, you float down, W glides you + forward, and you land without damage. The parachute is used up. +- [ ] Eject from a plane with an ejection seat (Space twice): the seat flies up, the canopy opens + once it falls, and you land safely. +- [ ] The fuel can, wrench and parachute are in the MCHeli Equipment tab with icons and names, and + craft with the original recipes. + +### Hull boxes and collisions + +- [ ] F3+B: cyan boxes (from the definition) and amber boxes (from the model) outline the hull of an + `ec665`, an `f-35b`, an `s-75` and an `md90`. The amber ones fill the MD-90's tail fin and nose. + The `mh-6` has amber boxes only. No boxes stand in a rotor disc. +- [ ] Fire the gun at the tail, the nose and the rotor mast of a parked helicopter: every round stops + on the hull (no tracer behind it), and the helicopter takes damage. +- [ ] Fire from a helicopter's own guns: no round stops inside the firing helicopter. +- [ ] Drive a tank into the side of a parked plane: it stops at the wing or fuselage, not at the + plane's feet. At speed both take crash damage. +- [ ] Land a helicopter on top of a parked truck: it rests there. +- [ ] A vehicle that fits a carrier's rack (a car and a truck with a rack) can still drive onto the + rack, and `J` loads it. +- [ ] Two vehicles placed into each other can drive apart. +- [ ] Shoot the MD-90's tail fin: the rounds stop on it and it takes damage. +- [ ] Destroy a parked MD-90 or C-5: three blasts a second or two apart, and the wreck stays on land. + A US-2 destroyed on the water sinks after its three blasts. Shoot down an MD-90 in the air: the + three blasts come when the wreck hits the ground. + +### Zoom and night vision + +- [ ] Gunner seat or gunner mode in an AH-64 (`H`): Z zooms 2x, 4x, then back to 1x; the HUD's zoom + readout follows. In the pilot seat without a sight, Z still folds the wings or hatch. +- [ ] K in a vehicle with night vision: the view brightens at night, K again turns it off. + Leaving the vehicle turns it off. A night vision potion is not cut short. +- [ ] K in a vehicle without night vision plays the "no" sound and does nothing. + ## Reporting problems Attach `logs/latest.log` (and the crash report, if any) with the helicopter name, what you did diff --git a/docs/images/phase-8/ec665-hull-boxes.png b/docs/images/phase-8/ec665-hull-boxes.png new file mode 100644 index 0000000..6869159 Binary files /dev/null and b/docs/images/phase-8/ec665-hull-boxes.png differ diff --git a/docs/images/phase-8/md-90-hull-boxes.png b/docs/images/phase-8/md-90-hull-boxes.png new file mode 100644 index 0000000..2fd64e1 Binary files /dev/null and b/docs/images/phase-8/md-90-hull-boxes.png differ diff --git a/docs/images/phase-8/plane-crash-site.png b/docs/images/phase-8/plane-crash-site.png new file mode 100644 index 0000000..ad56378 Binary files /dev/null and b/docs/images/phase-8/plane-crash-site.png differ diff --git a/docs/images/phase-8/plane-crash-spin.png b/docs/images/phase-8/plane-crash-spin.png new file mode 100644 index 0000000..728fc8f Binary files /dev/null and b/docs/images/phase-8/plane-crash-spin.png differ diff --git a/docs/images/phase-8/sa-2-hits-plane.png b/docs/images/phase-8/sa-2-hits-plane.png new file mode 100644 index 0000000..cfda571 Binary files /dev/null and b/docs/images/phase-8/sa-2-hits-plane.png differ diff --git a/docs/images/phase-8/sa-2-lock-on-plane.png b/docs/images/phase-8/sa-2-lock-on-plane.png new file mode 100644 index 0000000..5a32c1b Binary files /dev/null and b/docs/images/phase-8/sa-2-lock-on-plane.png differ 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 2cc5cb7..269072d 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 @@ -34,6 +34,7 @@ static void init() { 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(), event.getCameraState().cameraEntityPartialTicks); diff --git a/gradle.properties b/gradle.properties index e2d93a9..dd60f85 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.3 +mod_version=1.1.0-beta.4 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,neoforge-26.3,fabric-26.3,forge-1.21.1,neoforge-1.21.1,fabric-1.21.1 +platforms=forge-26.3 # Libraries the common module compiles against. Every supported Minecraft version ships them. slf4j_version=2.0.17 diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java index eef7dc3..6e9fa86 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -23,6 +23,7 @@ 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; @@ -49,6 +50,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"); @@ -110,6 +114,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()) { 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 50b9bed..2305d50 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 @@ -7,7 +7,9 @@ import com.jnhprojects.mcheli.client.render.A10Renderer; import com.jnhprojects.mcheli.client.render.AircraftRenderer; import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.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.sound.EngineSoundPlayer; @@ -41,6 +43,8 @@ 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() { } @@ -72,6 +76,7 @@ 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); } public static List keyMappings() { @@ -85,6 +90,11 @@ public static Map reloadListeners() { } 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(); WeaponController.onClientTick(); HudRenderer.tick(); @@ -169,6 +179,11 @@ 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 VehicleCamera.fov(fov); + } + /** Whether to skip drawing the first person hand this frame. */ public static boolean hidesHand() { return CrewController.hidesHand(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java index f1cce61..f0d52dd 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -99,6 +99,7 @@ static void onClientTick() { tracking = false; viewVehicle = null; viewSeat = -1; + VehicleCamera.reset(); return; } int seat = vehicle.seatOf(player); @@ -109,6 +110,7 @@ static void onClientTick() { cameraIndex = 0; gunnerSight = false; tracking = false; + VehicleCamera.reset(); } Input keys = player.input.keyPresses; @@ -164,10 +166,15 @@ 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()) { + VehicleCamera.onCameraModeKey(player, vehicle); + } while (KeyBindings.VTOL.consumeClick()) { if (pilot) { send(vehicle, CrewActionPacket.Action.VTOL); @@ -185,6 +192,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)); } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java index 7f0d39f..11f9426 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java @@ -21,7 +21,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); @@ -35,9 +35,11 @@ public final class KeyBindings { 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); + /** Night vision on and off 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); 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, RESUPPLY, CAMERA_MODE); private KeyBindings() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java new file mode 100644 index 0000000..56027d7 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java @@ -0,0 +1,81 @@ +package com.jnhprojects.mcheli.client; + +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.player.LocalPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; + +/** + * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} + * above one, and night vision 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 + } + + 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; + } + + /** + * {@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(); + int max = def != null ? def.cameraZoom : 1; + if (max <= 1 || vehicle.isDestroyed()) { + 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}: night vision on or off, which the server applies as an effect. */ + static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { + AircraftDefinition def = vehicle.definition(); + if (def == null || !def.nightVision || vehicle.isDestroyed()) { + refuse(player); + return; + } + mode = mode == Mode.NORMAL ? Mode.NIGHT_VISION : Mode.NORMAL; + ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + mode == Mode.NIGHT_VISION ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF)); + 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.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java index d63fc2a..135b865 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java @@ -1,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 { @@ -56,6 +56,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; @@ -196,15 +197,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(); @@ -216,6 +217,18 @@ void update(AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, v.set(HudVariable.UAV_FS, 0.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; @@ -327,7 +340,7 @@ 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 CAM_ZOOM -> VehicleCamera.zoom(); case UAV_DIST -> 0.0; case THROTTLE -> vehicle.visibleThrottle() * 100.0; }; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java index 8fc8cb0..a1aa623 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -15,6 +15,7 @@ import com.mojang.blaze3d.vertex.PoseStack; 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; @@ -22,23 +23,50 @@ 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.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; 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(); @@ -56,6 +84,9 @@ public void extractRenderState(T entity, AircraftRenderState state, float partia 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); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java index 404630a..807a460 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -66,6 +66,16 @@ public final class Glow { private Glow() { } + /** + * Hands both glow pipelines to Iris, so a shader pack draws them with a matching program: the + * halos and tracers as emissive entities, the particle as a translucent particle. 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"); + } + /** * 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. diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/IrisCompat.java new file mode 100644 index 0000000..e08b4ed --- /dev/null +++ b/minecraft-26.3/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.renderpearl.api.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.3/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ParachuteRenderer.java new file mode 100644 index 0000000..f07c03a --- /dev/null +++ b/minecraft-26.3/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.rotateDegrees(Axis.YP, -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.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java index 42514b8..116f7ac 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -82,13 +82,13 @@ public void extractRenderState(ProjectileEntity entity, State state, float parti state.model = entity.model(); state.yaw = entity.getYRot(partialTicks); state.pitch = entity.getXRot(partialTicks); - state.visible = !(entity instanceof BombEntity bomb) || bomb.isVisible(); + 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-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java index c17ba3c..31a66a6 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java @@ -12,8 +12,11 @@ import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.WrenchItem; import com.jnhprojects.mcheli.particle.GlowParticleOptions; import com.jnhprojects.mcheli.physics.AircraftRotation; +import com.jnhprojects.mcheli.physics.CrashSpin; import com.jnhprojects.mcheli.physics.GroundImpact; import com.jnhprojects.mcheli.registry.ModItems; import com.jnhprojects.mcheli.weapon.Explosions; @@ -99,6 +102,7 @@ public abstract class AircraftEntity extends VehicleEntity { 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); // Bits of DATA_PARTS, the same assignment the original used for its part status word. protected static final int PART_CANOPY = 0; @@ -150,6 +154,10 @@ public abstract class AircraftEntity extends VehicleEntity { /** Damage factor of the extra hit box the last projectile ray struck, {@code lastBBDamageFactor}. */ private float hitBoxFactor = 1.0F; private int timeSinceHit; + /** The night vision effect a crew member gets, topped up before it runs low: 15 seconds. */ + private static final int NIGHT_VISION_TICKS = 300; + /** 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. */ @@ -158,9 +166,8 @@ public abstract class AircraftEntity extends VehicleEntity { private int despawnCount; /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ private int wreckTicks; - private float destroyedYaw; - private float destroyedPitch; - private float destroyedRoll; + /** 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; @@ -168,9 +175,21 @@ public abstract class AircraftEntity extends VehicleEntity { 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; + // 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. */ @@ -202,6 +221,7 @@ protected void defineSynchedData(SynchedEntityData.Builder builder) { builder.define(DATA_TV_MISSILE, 0); builder.define(DATA_FLARE, false); builder.define(DATA_SINKING, false); + builder.define(DATA_FUEL, 0); } // Definition @@ -266,6 +286,7 @@ 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(); } @@ -283,6 +304,175 @@ 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; + } + + /** + * 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); + endNightVision(player); + } + } + + /** + * {@code MCH_Camera.updateViewer}: keeps a short night vision effect topped up on everyone who + * switched it on. It never runs below ten seconds, where vanilla starts to flicker the view. + */ + private void tickNightVision() { + if (nightVisionSeats == 0) { + return; + } + if (isDestroyed()) { + for (int seat = 0; seat < seatIds.length; seat++) { + if (occupant(seat) instanceof LivingEntity crew) { + endNightVision(crew); + } + } + nightVisionSeats = 0; + return; + } + for (int seat = 0; seat < seatIds.length; seat++) { + if (hasNightVision(seat) && occupant(seat) instanceof LivingEntity crew) { + MobEffectInstance current = crew.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < NIGHT_VISION_TICKS - 40) { + crew.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, NIGHT_VISION_TICKS, 0, true, false, false)); + } + } + } + } + + /** 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; + } + + /** Takes our night vision away again, but leaves a potion's longer one alone. */ + private static void endNightVision(Entity entity) { + if (entity instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= NIGHT_VISION_TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } + + /** + * {@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; + } + } + /** Whether the flight model holds a hover: in hover mode, or while the pilot mans the guns. */ public boolean isHovering() { return isHoveringMode() || isGunnerMode(); @@ -431,10 +621,20 @@ public void tick() { double fallBefore = getDeltaMovement().y; tickDestroyed(); + if (!level().isClientSide()) { + supplyFuel(def); + burnFuel(def); + tickNightVision(); + } + if (isSimulatingHere()) { if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { input.clear(); } + 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 { @@ -531,8 +731,14 @@ private void tickImpacts(AircraftDefinition def, boolean groundedBefore, double hurtServer((ServerLevel) level(), damageSources().flyIntoWall(), crashDamage(previous.horizontalDistance())); } if (speed > VEHICLE_CRASH_SPEED && collisionCooldown == 0) { - for (AircraftEntity other : level().getEntitiesOfClass(AircraftEntity.class, getBoundingBox().inflate(0.5), - o -> o != this && !o.isRemoved() && o.getVehicle() != this && getVehicle() != o)) { + // 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) { @@ -918,6 +1124,10 @@ protected void removePassenger(Entity passenger) { } int seat = seatOf(passenger); if (seat >= 0) { + if (hasNightVision(seat)) { + nightVisionSeats &= ~(1 << seat); + endNightVision(passenger); + } lastSeats.put(passenger.getId(), seat); seatIds[seat] = EMPTY; entityData.set(DATA_SEATS, encodeSeats()); @@ -1014,6 +1224,13 @@ public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { @Override public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + ItemStack held = player.getItemInHand(hand); + 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); if (result != InteractionResult.PASS || player.isSecondaryUseActive()) { return result; @@ -1195,8 +1412,8 @@ public boolean canEject(Entity entity) { } /** - * Throws the occupant clear of the aircraft. Without the parachute item (a later phase) the - * crew member gets a few seconds of slow falling instead. Server only. + * {@code ejectSeat}: throws the occupant clear of the aircraft in an ejection seat, whose + * parachute opens once it starts to fall. Server only. */ public void eject(Entity entity) { AircraftDefinition def = definition(); @@ -1212,9 +1429,7 @@ public void eject(Entity entity) { 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; - if (entity instanceof LivingEntity living) { - living.addEffect(new MobEffectInstance(MobEffects.SLOW_FALLING, 400)); - } + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.EJECTION_SEAT); if (canopy != null && !canopy.status()) { canopy.set(true, false); modeSwitchCooldown = 40; @@ -1444,11 +1659,8 @@ public Optional clipHitBoxes(Vec3 from, Vec3 to, double inflate) { double bestDistance = best.map(from::distanceToSqr).orElse(Double.MAX_VALUE); AircraftDefinition def = definition(); if (def != null) { - for (BoundingBoxDefinition box : def.boundingBoxes) { - Vec3 c = toWorld(box.offset()); - double hw = box.width() / 2.0 + inflate; - double hh = box.height() / 2.0 + inflate; - Optional hit = clip(new AABB(c.x - hw, c.y - hh, c.z - hw, c.x + hw, c.y + hh, c.z + hw), from, to); + 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()); @@ -1459,6 +1671,136 @@ public Optional clipHitBoxes(Vec3 from, Vec3 to, double inflate) { 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); @@ -1548,7 +1890,7 @@ public boolean hurtServer(ServerLevel level, DamageSource source, float damage) entityData.set(DATA_HP, 0); destroyVehicle(); timeSinceHit = 20; - if (isAfloat()) { + 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(); @@ -1583,9 +1925,6 @@ private boolean hasPlayerPassenger() { private void destroyVehicle() { AircraftDefinition def = definition(); despawnCount = def != null && def.targetDrone ? 50 : WRECK_TICKS; - destroyedPitch = random.nextFloat() - 0.5F; - destroyedRoll = (random.nextFloat() - 0.5F) * 0.5F; - destroyedYaw = 0.0F; setHovering(false); if (def != null && def.uav) { Entity remote = occupant(0); @@ -1606,6 +1945,10 @@ 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); @@ -1616,6 +1959,15 @@ private void crashExplosion() { } } + /** + * 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(); @@ -1623,9 +1975,9 @@ private boolean isAfloat() { } /** - * A boat destroyed on the water does not go up in one crash fireball; its fuel and magazine - * cook off in three blasts over about five seconds, then the hull sinks. Not in the original, - * which only had the crash explosion. + * 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; @@ -1657,7 +2009,10 @@ private void tickCookOff(ServerLevel level) { // Three blasts inside five seconds: about 1.5 to 2.5 seconds apart. cookOffDelay = 30 + random.nextInt(20); } else { - entityData.set(DATA_SINKING, true); + // 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; } } @@ -1714,16 +2069,7 @@ private void tickDestroyed() { } input.clear(); throttle *= 0.98; - if (this instanceof HelicopterEntity) { - destroyedYaw = Math.min(destroyedYaw + 0.3F, 15.0F); - setYRot(getYRot() + destroyedYaw * (float) throttle); - if (!isNearGround(3)) { - if (Math.abs(getXRot()) < 10.0F) { - setXRot(getXRot() + destroyedPitch); - } - roll += destroyedRoll; - } - } + crashSpin(); if (!(level() instanceof ServerLevel level)) { return; } @@ -1743,6 +2089,27 @@ private void tickDestroyed() { } } + /** + * 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; @@ -1838,6 +2205,7 @@ 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()); @@ -1862,6 +2230,8 @@ protected void readAdditionalSaveData(ValueInput input) { 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)); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ParachuteEntity.java new file mode 100644 index 0000000..eb5c35f --- /dev/null +++ b/minecraft-26.3/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.LinearInterpolationHandler; +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 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, 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 + protected InterpolationHandler createInterpolationHandler() { + return LinearInterpolationHandler.create(this, 3); + } + + @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.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java index c8b5d6e..30f171c 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java @@ -49,6 +49,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; @@ -67,6 +70,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. */ @@ -84,6 +89,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); } /** @@ -154,8 +161,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. */ @@ -164,13 +170,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() { @@ -223,6 +230,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) { @@ -536,8 +546,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; } @@ -604,6 +627,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) { @@ -727,6 +757,11 @@ public boolean hurtServer(ServerLevel level, 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-26.3/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/FuelItem.java new file mode 100644 index 0000000..aa2113b --- /dev/null +++ b/minecraft-26.3/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. + * + *

The original poured the can into a vehicle through the fuel slots of the vehicle's inventory + * screen. That screen does not exist yet, so right-clicking a vehicle with the can pours up to 100 + * units at a time instead. + */ +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.3/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ParachuteItem.java new file mode 100644 index 0000000..2af7b79 --- /dev/null +++ b/minecraft-26.3/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.3/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/WrenchItem.java new file mode 100644 index 0000000..8ad8f03 --- /dev/null +++ b/minecraft-26.3/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.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java index 8e7136f..0ec1652 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java @@ -47,7 +47,11 @@ public enum Action { /** Pay the selected weapon's {@code Item} cost from the inventory for one resupply. */ RESUPPLY, /** Hand the controls to the autopilot and man the weapons, or take them back. Pilot only. */ - GUNNER_MODE + 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 } public static void encode(CrewActionPacket msg, FriendlyByteBuf buf) { @@ -132,6 +136,7 @@ public static void handle(CrewActionPacket msg, ServerPlayer sender) { } } case RESUPPLY -> Resupply.fromInventory(sender, vehicle); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java index 464780e..8630baa 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -21,7 +21,7 @@ */ public final class ModNetwork { /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - public static final int PROTOCOL_VERSION = 6; + public static final int PROTOCOL_VERSION = 7; /** Client to server. The handler gets the sending player. */ public static final List> SERVERBOUND = List.of( diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java index 8b24af0..fdd851d 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -16,8 +16,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. The vehicle 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 +26,12 @@ 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()))) + .build()); private ModCreativeTabs() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java index c16131e..c881124 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java @@ -7,6 +7,7 @@ import com.jnhprojects.mcheli.entity.AircraftEntity; 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.TankEntity; import com.jnhprojects.mcheli.entity.projectile.A10Entity; @@ -65,6 +66,13 @@ public final class ModEntities { .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)); + private ModEntities() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java index 5292745..24b3055 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -4,17 +4,22 @@ import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.FuelItem; import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import com.jnhprojects.mcheli.item.ParachuteItem; +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.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) has one item each. */ public final class ModItems { public static final Registered HELICOPTER = kind("helicopter"); @@ -22,6 +27,13 @@ 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().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))); + + /** The equipment tab, in the order it lists them. */ + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE); + private static final Map> BY_KIND = Map.of("helicopter", HELICOPTER, "plane", PLANE, "tank", TANK, "vehicle", VEHICLE); @@ -40,7 +52,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-26.3/src/main/resources/data/mcheli/recipe/fuel.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fuel.json new file mode 100644 index 0000000..d076b04 --- /dev/null +++ b/minecraft-26.3/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.3/src/main/resources/data/mcheli/recipe/parachute.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/parachute.json new file mode 100644 index 0000000..69eab19 --- /dev/null +++ b/minecraft-26.3/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.3/src/main/resources/data/mcheli/recipe/wrench.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/wrench.json new file mode 100644 index 0000000..b8c9bcf --- /dev/null +++ b/minecraft-26.3/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" + } +}