diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ce567..0eb7009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,82 @@ 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.6] - 2026-11-01 + +Phase 10 of the road to 1.1.0: the rest of the equipment. Betas are not uploaded to CurseForge, +and this one ships the Forge 26.3 jar only. Worlds from 1.1.0-beta.5 load as before; the original +content is imported again on the first start. Players and servers need the same version, since +the network protocol changed (9). + +### Added + +- M18 smoke grenades in red, green, violet and yellow, from the original `throwable` files. Hold + use to draw, release to throw: the longer the draw, the further it goes. They bounce off blocks + and pour out coloured smoke for half a minute. Dispensers throw them too. Packs can add their own + throwables in a `throwable` folder; they share one `mcheli:throwable` item, like the vehicles. +- The GLTD target designator. Set it on the ground, right-click to sit behind it, and look through + its sight (70 degrees either way). Z zooms, K steps through night vision and thermal view, and + use marks the spot under the cross: an A-10 makes three gun runs over it. The sight shows when + the next A-10 can be called (about 22 seconds). +- The laser rangefinder. Held up to the eye it is a scope that reads the range; Z and X zoom in and + out, C picks what to spot (players and vehicles, mobs, or a point), and the attack key spots. + Spotting uses one of ten charges; crafting it with redstone charges it again. +- Spotted players, mobs and vehicles show up for the spotter and their team only, as markers on + the HUD: a triangle over the target with its distance, or a diamond at the screen edge when it is + out of view. They last 15 seconds (mobs a minute). Teammates are never spotted. A marked point + gets its own marker with distance and coordinates for a minute, and goes to the team's chat. +- The cargo container: a crate with a large chest's worth of room that floats and can be pushed. + Racks that list `container` load and drop it like vehicles (J and U), and dropped with no ground + within 20 blocks it comes down under a parachute. +- The FIM-92 Stinger and FGM-148 Javelin, shoulder launched. Hold use to raise the sight, keep a + target in the cross until it locks (aircraft for the Stinger, ground vehicles for the Javelin), + then attack to fire. Z zooms, and X switches the Javelin between direct and top attack. Each + holds one missile: lower an empty launcher with a missile in your inventory to reload it. +- The tow chain. Use it on both ends, in either order, 2 to 16 blocks apart; one end must be a + vehicle, which always does the pulling, so a helicopter can lift a container on a sling. Use it + on either end again to take it off. Each step shows a message above the hotbar. +- The UAV station and the portable UAV controller. Sit at one, put a drone in its slot and fly it + from there with the usual controls, HUD and weapons; the HUD shows the link's range and + strength. The launch point is set with the station's buttons, Continue takes the last drone + again, and Shift leaves it. The portable controller only takes small UAVs and opens with Shift + and right-click. A drone flown more than 123 blocks from its station is lost. A target drone + goes up with a full tank and flies itself, as something to shoot at. While flying, the pilot + is hidden and unhurt, and comes back beside the station when they leave or lose the drone. +- Troops. When the pilot climbs in, mobs standing close by take the free crew seats. Y lets them + out: helicopters with rappelling hooks lower ropes from a steady hover and the mobs slide down + them; vehicles with `EnableParachuting` open the hatch and drop them one by one under parachutes + at the drop point; near the ground they simply get out. Players in the back press Y to slide down + a rope or jump with a parachute themselves. +- Search lights, for vehicles that list them: K switches them on and off from the pilot or first + crew seat. A turning light follows the crew's aim, a fixed one points along the vehicle and a + steering one turns with the front wheels. +- Helicopters with `EnableFoldBlade` fold their blades with Z when the engine is stopped, and + cannot take off until they unfold them; folded on the ground, A and D turn them slowly. +- Rotor downwash raises dust or spray under a helicopter, boats with `EnableSeaSurfaceParticle` + throw spray off the sea, `AddParticleSplash` points spray through water, jet nozzles smoke with + the throttle, and fast landing gear kicks up the ground. +- Targeting pods that mark blocks now mark the point under the crew's view for the team, and + mortars show where their shell would land on the HUD. +- The drafting table. Right-click it to browse every MCHeli recipe by list (helicopters, planes, + vehicles, tanks, equipment): the recipe, a vehicle's numbers or the original's illustrated + description, and Create to make the item into the output slot from your inventory. Shift and + right-click switches its lamp. It is drawn with the original model. +- The team manager, for operators, on M: switch PvP, shuffle every player into the teams, make a + team with a colour and friendly fire, send everyone to their spawn point, or remove every + vehicle. The teams and their players are listed beside it. +- Recipes for the smoke grenades, GLTD, rangefinder, container, chain, launchers and missiles, + UAV station, portable controller and drafting table, from the original config. + +### Changed + +- The equipment tab lists the new items and every loaded throwable, and is rebuilt after a content + reload. +- The E-767 can be crafted at last: its recipe needs a UAV station. The content is imported again + on the first start (import format 4). +- The README is shorter: the full controls, equipment guide, commands and settings moved to + [docs/PLAYING.md](docs/PLAYING.md), and the screenshots of earlier phases to + [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md). + ## [1.1.0-beta.5] - 2026-10-31 Phase 9 of the road to 1.1.0: the vehicle screen and what goes with it, supply vehicles and @@ -552,6 +628,7 @@ Phase 1: foundation and flyable helicopters. - Free look moved from Left Ctrl to Left Alt, because Left Ctrl is now the vanilla sprint key. - Roll damping runs per tick instead of per frame, so handling no longer depends on frame rate. +[1.1.0-beta.6]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.6 [1.1.0-beta.5]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.5 [1.1.0-beta.4]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.4 [1.1.0-beta.3]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.3 diff --git a/README.md b/README.md index c0c773f..95b7cea 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ MCHeli Rework is a ground-up port of MCHeli 1.0.3, a mod that stopped at Minecra system is rewritten for the modern game, while the original vehicle definitions, models, textures and sounds load unchanged. -> **Status:** Phase 3 of 4 (`1.0.0-alpha.3`). Every helicopter, plane, tank, car and ground vehicle -> can be placed, crewed and driven with the original handling, and all 229 weapons fire: guns, -> rockets, bombs, guided missiles, torpedoes, close air support, flares, with damage, armour and -> destroyed wrecks. The original's scripted HUDs and the equipment items follow in phase 4; see the +> **Status:** on the way to 1.1.0, now at phase 10 (`1.1.0-beta.6`). Every helicopter, plane, +> tank, car and ground vehicle can be placed, crewed and driven with the original handling, all +> 229 weapons fire, and the scripted HUDs, fuel, the vehicle screen and all of the original's +> equipment are in, down to UAV stations, the drafting table and the team manager. Betas 4 to 8 +> ship the Forge 26.3 jar only; every loader and Minecraft version comes back for 1.1.0. See the > [roadmap](docs/ROADMAP.md). ## Showcase video @@ -30,89 +31,20 @@ Phase 4 clips (click a picture to open the video): ## Screenshots -Phase 9 in a development build (`1.1.0-beta.5`): thermal view, the vehicle screen, ejection seats -with parachutes from the vehicle, and cargo. +Phase 10 in a development build (`1.1.0-beta.6`): smoke grenades, the GLTD target designator, the +rangefinder, cargo containers and the tow chain. Every earlier phase is in the +[screenshot gallery](docs/SCREENSHOTS.md). | | | |---|---| -| ![The AH-64 cockpit in thermal view: the desert in white hot greys with outlines, the green HUD reading THERMAL VISION](docs/images/phase-9/ah-64-thermal-view.png) | ![The cargo chest of a B1 Centauro, two rows of slots above the player inventory](docs/images/phase-9/centauro-cargo-chest.png) | -| Thermal view from the AH-64's cockpit | The Centauro's cargo chest, opened from outside | -| ![The AH-64's vehicle screen: fuel slots on the left, the M230 chain gun at 15 of 1200 rounds with its reload cost of iron and gunpowder](docs/images/phase-9/vehicle-screen-empty-reserve.png) | ![The same screen after reloading, the chain gun at 1200 of 1200 in green and the Reload button greyed out](docs/images/phase-9/vehicle-screen-reloaded.png) | -| The vehicle screen with the chain gun's reserve empty | Reloaded from the player's own iron and gunpowder | -| ![A player in an ejection seat climbing away above a desert lake after leaving an F-35B](docs/images/phase-9/ejection-seat.png) | ![Looking down the parachute's rigging lines at the player in the seat, the F-35B and an AH-64 below](docs/images/phase-9/ejection-parachute-open.png) | -| Ejecting from an F-35B, with a parachute from its slot | The canopy opens once the seat falls | - -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. - -| | | -|---|---| -| ![An SA-2 closing on a helicopter in the night sky, its motor glowing](docs/images/phase-6/sa-2-night-intercept.png) | ![The shot down helicopter exploding on a hilltop at night in a bright flash](docs/images/phase-6/helicopter-crash-night.png) | -| An SA-2 intercept at night | The helicopter goes up in a flash of light | -| ![Glowing embers and burning debris arcing over the burning wreck at night](docs/images/phase-6/crash-embers-night.png) | ![A torpedo running towards a patrol boat at dusk](docs/images/phase-6/torpedo-run-dusk.png) | -| Burning debris arcs over the wreck | A torpedo run at dusk | -| ![The patrol boat cooking off in a fireball against the sunset](docs/images/phase-6/patrol-boat-cook-off-dusk.png) | ![Glowing debris raining down around the sinking patrol boat](docs/images/phase-6/burning-debris-dusk.png) | -| The patrol boat cooks off | Glowing debris rains down as it sinks | - -Phase 5 in a development build (`1.1.0-beta.1`): a patrol boat hit by a torpedo cooks off and -sinks. - -| | | -|---|---| -| ![A patrol boat exploding on a river after a torpedo hit, burning debris arcing over the shore](docs/images/phase-5/patrol-boat-cook-off.png) | ![The wreck of the patrol boat resting on the riverbed, listing and still smoking](docs/images/phase-5/patrol-boat-wreck.png) | -| Torpedo hit: fuel and ammunition cook off in three blasts | The wreck settles on the bottom, still smoking | - -Phase 4 in a development build: the original's scripted HUDs, pilot gunner mode and lock-on. - -| | | -|---|---| -| ![A helicopter cockpit in flight with the helicopter HUD and radar](docs/images/phase-4/helicopter-hud.png) | ![The S-75 launcher's sight with the SA-2 selected](docs/images/phase-4/s-75-launcher.png) | -| Helicopter HUD: heading, pitch ladder, weapon, radar | The S-75 launcher with its SA-2 Guideline | -| ![The AC-130 gunner view with another plane in the sight](docs/images/phase-4/ac-130-gunner-mode.png) | ![The AC-130 gunner HUD over the jungle](docs/images/phase-4/ac-130-gunner-hud.png) | -| AC-130 gunner mode: the autopilot circles while the pilot aims the guns | The gunner HUD with the Bofors 40 mm selected | -| ![An SA-2 exploding on a helicopter right above the launcher](docs/images/phase-4/sa-2-strike.png) | ![The smoke and burning debris of an SA-2 hit](docs/images/phase-4/sa-2-burst.png) | -| An SA-2 strikes a helicopter over the launcher | The burst of a hit in mid-air | - -Phase 3 in a development build: weapons, damage, crashes and the explosion effect. - -| | | -|---|---| -| ![A T-90 burning after a hit from another T-90](docs/images/phase-3/tank-duel.png) | ![A shot down helicopter exploding as it hits the ground](docs/images/phase-3/helicopter-crash.png) | -| Tank duel: the loser burns | A shot down helicopter explodes on impact | -| ![An M61 Vulcan bringing down a helicopter](docs/images/phase-3/vulcan-shoot-down.png) | ![A damaged SH-3 Sea King smoking on the water](docs/images/phase-3/damaged-sea-king.png) | -| An M61 Vulcan bringing down a helicopter | A damaged Sea King smokes, afloat on its floats | -| ![The view from a T-90 turret with the weapon readout](docs/images/phase-3/tank-gunner.png) | ![Water seen through a plane's canopy](docs/images/phase-3/canopy-over-water.png) | -| From the T-90 turret, reloading the main gun | Glass canopies show the water behind them | - -Phase 2 in a development build: tanks, cars, ground launchers and planes with their moving parts. - -| | | -|---|---| -| ![A tracked tank and an eight-wheeled armoured car](docs/images/phase-2/tank-and-armoured-car.png) | ![Tanks, an armoured car and an S-75 missile launcher](docs/images/phase-2/ground-forces.png) | -| A tracked tank and an eight-wheeled armoured car | Ground forces next to an S-75 launcher | -| ![Tornado with its landing gear down](docs/images/phase-2/tornado.png) | | -| Tornado on the ground, gear down and stores on its pylons | | - -Phase 1 running on Minecraft 26.3 with Forge 66.1.0. - -| | | -|---|---| -| ![Cockpit view while flying, banked to the right](docs/images/phase-1/cockpit-in-flight.png) | ![SH-3 Sea King hovering](docs/images/phase-1/sh-3-hovering.png) | -| Flying from the cockpit, with camera roll | SH-3 Sea King in the air | -| ![SH-3 Sea King climbing](docs/images/phase-1/sh-3-climbing.png) | ![The MCHeli Helicopters creative tab](docs/images/phase-1/creative-tab.png) | -| Main and tail rotors spinning | All 33 helicopters in the creative tab | +| ![A green M18 smoke cloud rising from a hilltop by a lake](docs/images/phase-10/m18-green-smoke.png) | ![The view through the GLTD sight over a desert village, a red cross on the target and the readout "A-10 on call"](docs/images/phase-10/gltd-sight.png) | +| An M18 smoke grenade marks a spot in green | Through the GLTD's sight, with the A-10 on call | +| ![Two A-10s pulling away over a desert hill after strafing it, the readout counting down "A-10 rearming"](docs/images/phase-10/gltd-a-10-strike.png) | ![A player sitting behind the GLTD tripod, its red laser running out over the sand](docs/images/phase-10/gltd-tripod.png) | +| The A-10 makes its gun runs over the marked spot | The GLTD from outside: the head follows the aim, with a faint laser | +| ![The rangefinder scope on an AH-64 at 163 metres, the spot modes listed at the side](docs/images/phase-10/rangefinder-scope.png) | ![A cargo container coming down over a desert under two parachutes](docs/images/phase-10/container-parachute-drop.png) | +| The rangefinder reads the range to an AH-64 | A container dropped from a rack comes down under a parachute | +| ![A cargo container chained to the side of an M1A2 tank](docs/images/phase-10/chain-tow-m1a2.png) | ![A CH-47 flying over canyons with a container hanging below it on the tow chain](docs/images/phase-10/ch47-sling-load.png) | +| An M1A2 with a container on the tow chain | A CH-47 carries a container as a sling load | ## Credits @@ -185,126 +117,17 @@ versions in a shared `mods` folder will stop the game from starting. Worlds and folder can move between loaders on the same Minecraft version: the imported content, content packs and configs are the same files on all three. Do not open a 26.3 world in 1.21.1. -## Controls - -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. - -In survival, vehicles are crafted on a crafting table with the original's recipes (mostly iron -blocks and ingots, glass, redstone and dyes). They appear in the recipe book as soon as you join. -A few vehicles had no recipe in the original and are still creative only: the M1129, M26, MXT-MV, -fuel truck and ammo box, and the E-767, whose recipe needs the UAV station that is not ported yet. - -**Helicopters** - -| Input | Action | -|-------|--------| -| Mouse | Pitch, bank and turn. The view follows the nose. | -| W / S | Throttle up / down | -| A / D | Bank left / right | -| Space | Toggle hover mode. Hover holds altitude; W/A/S/D then slide the helicopter. | - -Lift drops as the helicopter tilts, so pitch the nose down gently to pick up speed and level out -to climb. - -**Planes** - -| Input | Action | -|-------|--------| -| Mouse | Pitch, roll and turn | -| 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. Through a sight, zoom instead. | -| V | VTOL mode, for planes that have it | -| Space, twice | Eject, for planes with ejection seats | - -**Tanks and cars** - -| Input | Action | -|-------|--------| -| Mouse | Look around; the turret follows the view | -| W / S | Throttle; S brakes and then reverses where the vehicle can | -| A / D | Turn | -| Space (hold) | Hand brake | - -**Everyone** - -| Input | Action | -|-------|--------| -| Left Alt (hold) | Free look. On tanks it swaps between looking around and looking along the hull. | -| H | Pilot: next camera position, or gunner mode on vehicles that have it (the autopilot flies while you man the guns). Gunners: toggle the gunner sight. | -| 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, then thermal view, then back to normal, in vehicles that have night vision | -| I | The vehicle screen: fuel and parachute slots, reloading, and the cargo chest | -| Shift | Leave the vehicle | - -**Weapons** - -| Input | Action | -|-------|--------| -| Right mouse (hold) | Fire the selected weapon. Seeking missiles lock on while held and fire once locked. | -| Middle mouse or G | Next weapon for your seat; with Shift, the previous one | -| X | Fire mode, for weapons that have one (HE rounds, top attack, TV guidance) | -| C | Release flares (pilot and first crew seat) | - -Each seat fires the weapons its vehicle assigns to it; the pilot also fires a gunner's weapons -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, or put cans in the fuel slots of the vehicle -screen (I), which empty into the tank while the vehicle is on the ground. Right-click in the air -with coal or charcoal in your inventory to fill a can again. A fuel truck tops up every vehicle -around it, and an ammunition truck rearms them. 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 need a parachute in the vehicle screen's -parachute slots, one per ejection. All three are crafted with the original's recipes. - -The vehicle screen also reloads weapons from your own items, one supply per click, while the -vehicle is on the ground; closing it loads every magazine. Vehicles with cargo space have a -**Cargo** button there, and Shift + right-click opens their cargo chest from outside. What a -vehicle carries drops when it is destroyed. - -## Commands and server settings - -| Command | Who | What it does | -|---------|-----|--------------| -| `/mcheli` | everyone | Where the vehicles were loaded from, and how many of each kind | -| `/mcheli list [helicopter\|plane\|tank\|vehicle]` | everyone | The loaded vehicles by name | -| `/mcheli give [count]` | operators | Hands out vehicles, for example `/mcheli give @s ah-64` | -| `/mcheli packs` | everyone | The content packs in use, and any problems with them | -| `/mcheli reload` | operators | Reads the vehicle and weapon files and the content packs again without a restart, and sends players the new packs | - -Each world has `serverconfig/mcheli-server.toml` (created on first start, edits apply while the -server runs): - -| Setting | Default | Meaning | -|---------|---------|---------| -| `explosions.breakBlocks` | `true` | Weapons and crashes break blocks (the `mobGriefing` rule applies too) | -| `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 | -| `packs.maxTotalSize` | `64` | Most the server loads and sends in all, in MB | - -Each player also has `config/mcheli-client.toml`, which only changes what that player sees: - -| Setting | Default | Meaning | -|---------|---------|---------| -| `effects.nightGlow` | `true` | Tracers, rocket motors, flares and explosions glow: faintly by day, brightly at night, in caves and under water | -| `packs.cacheSize` | `256` | How much the content packs downloaded from servers may take up in `mcheli/cache`, in MB | +## Playing + +Take a vehicle from one of the MCHeli creative tabs, right-click a block to place it and +right-click the vehicle to board; in survival, craft it with the original's recipe. W and S work +the throttle, the mouse flies or aims, right mouse fires and Shift gets out. Every key can be +rebound under Controls, category MCHeli. + +The full controls for each kind of vehicle, fuel and repairs, the equipment (parachute, smoke +grenades, GLTD, rangefinder, Stinger and Javelin, container, UAV station, drafting table, tow +chain), troops, the team manager and the commands and server settings are in +[docs/PLAYING.md](docs/PLAYING.md). ## Content packs @@ -316,6 +139,8 @@ adds a small helicopter, the Rework Scout. To make your own, see ## Documentation +* [Playing](docs/PLAYING.md): controls, equipment, commands and server settings +* [Screenshots](docs/SCREENSHOTS.md): every phase in pictures * [Changelog](CHANGELOG.md) * [Roadmap](docs/ROADMAP.md): what each phase delivered, and what is planned for 1.1.0 * [Architecture](docs/ARCHITECTURE.md): how the mod is put together 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 0ca7c32..680e688 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java @@ -7,6 +7,7 @@ import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.content.pack.PackProblem; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import com.jnhprojects.mcheli.model.HullFill; import com.jnhprojects.mcheli.model.MeshLoader; @@ -33,7 +34,7 @@ import java.util.stream.Stream; /** - * Loaded vehicle and weapon definitions, keyed by file name. + * Loaded vehicle, weapon and throwable definitions, keyed by file name. * *

Definitions come from one or more {@linkplain ContentSource sources}: the imported 1.0.3 * content and the content packs. A definition in a later source replaces the one with the same @@ -61,7 +62,7 @@ private ContentRegistry(Snapshot snapshot) { /** A registry with nothing in it, for when no content could be found. */ public static ContentRegistry empty() { - return new ContentRegistry(new Snapshot(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), List.of())); + return new ContentRegistry(new Snapshot(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), List.of())); } /** @@ -88,6 +89,7 @@ public static ContentRegistry load(List sources) { loader.folder(source, PlaneDefinition.FOLDER, PlaneDefinition::new, loader::vehicle); loader.folder(source, TankDefinition.FOLDER, TankDefinition::new, loader::vehicle); loader.folder(source, VehicleDefinition.FOLDER, VehicleDefinition::new, loader::vehicle); + loader.folder(source, ThrowableDefinition.FOLDER, ThrowableDefinition::new, loader::throwable); } return new ContentRegistry(loader.finish(sources.size())); } @@ -99,6 +101,7 @@ private static final class Loader { private final Map weapons = new LinkedHashMap<>(); private final Map vehicles = new LinkedHashMap<>(); + private final Map throwables = new LinkedHashMap<>(); private final Map> unhandled = new TreeMap<>(); private final List problems = new ArrayList<>(); @@ -178,6 +181,10 @@ void vehicle(AircraftDefinition def) { replaced(vehicles.put(def.name(), def), def, def.folder()); } + void throwable(ThrowableDefinition def) { + replaced(throwables.put(def.name(), def), def, ThrowableDefinition.FOLDER); + } + private static void replaced(@Nullable Definition previous, Definition def, String folder) { if (previous != null && !previous.pack().equals(def.pack())) { LOGGER.info("{} replaces {}/{} from {}", def.pack(), folder, def.name(), previous.pack()); @@ -215,12 +222,12 @@ Snapshot finish(int sources) { } } - LOGGER.info("Loaded {} helicopters, {} planes, {} tanks, {} vehicles and {} weapons from {} sources", - helicopters.size(), planes.size(), tanks.size(), other.size(), weapons.size(), sources); + LOGGER.info("Loaded {} helicopters, {} planes, {} tanks, {} vehicles, {} weapons and {} throwables from {} sources", + helicopters.size(), planes.size(), tanks.size(), other.size(), weapons.size(), throwables.size(), sources); if (!unhandled.isEmpty()) { LOGGER.debug("Definition keys not implemented yet: {}", unhandled.keySet()); } - return new Snapshot(helicopters, planes, tanks, other, weapons, unhandled, problems); + return new Snapshot(helicopters, planes, tanks, other, weapons, throwables, unhandled, problems); } } @@ -329,6 +336,14 @@ public Collection weapons() { return snapshot.weapons.get(name); } + public Collection throwables() { + return snapshot.throwables.values(); + } + + public @Nullable ThrowableDefinition throwable(String name) { + return snapshot.throwables.get(name); + } + /** Every definition of every kind: helicopters, planes, tanks, then vehicles. */ public List all() { Snapshot s = snapshot; @@ -361,7 +376,8 @@ public List problems() { /** One consistent state of the registry. Never changed once published. */ private record Snapshot(Map helicopters, Map planes, Map tanks, Map vehicles, - Map weapons, Map> unhandledKeys, + Map weapons, Map throwables, + Map> unhandledKeys, List problems) { Snapshot { helicopters = Collections.unmodifiableMap(helicopters); @@ -369,6 +385,7 @@ private record Snapshot(Map helicopters, Map helicopters, Map map) { - return new Snapshot(map, planes, tanks, vehicles, weapons, unhandledKeys, problems); + return new Snapshot(map, planes, tanks, vehicles, weapons, throwables, unhandledKeys, problems); } Snapshot withPlanes(Map map) { - return new Snapshot(helicopters, map, tanks, vehicles, weapons, unhandledKeys, problems); + return new Snapshot(helicopters, map, tanks, vehicles, weapons, throwables, unhandledKeys, problems); } Snapshot withTanks(Map map) { - return new Snapshot(helicopters, planes, map, vehicles, weapons, unhandledKeys, problems); + return new Snapshot(helicopters, planes, map, vehicles, weapons, throwables, unhandledKeys, problems); } Snapshot withVehicles(Map map) { - return new Snapshot(helicopters, planes, tanks, map, weapons, unhandledKeys, problems); + return new Snapshot(helicopters, planes, tanks, map, weapons, throwables, unhandledKeys, problems); } Snapshot withWeapons(Map map) { - return new Snapshot(helicopters, planes, tanks, vehicles, map, unhandledKeys, problems); + return new Snapshot(helicopters, planes, tanks, vehicles, map, throwables, unhandledKeys, problems); } private static Map remove(Map map, String name) { diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java b/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java index 1713018..50c5055 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java @@ -46,9 +46,10 @@ public final class LegacyImporter { /** * Bump whenever the output changes, so existing installs import again. 2: recipes craft the * generic vehicle items of 1.1.0. 3: vehicle ingredients name their type for every loader, and - * the output depends on the {@link ImportTarget}. + * the output depends on the {@link ImportTarget}. 4: recipes may use the equipment + * items, so the E-767 is crafted with a UAV station. */ - public static final int FORMAT = 3; + public static final int FORMAT = 4; /** * Written at the pack root for {@link ImportTarget#V1_21_1}: each original vehicle's 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 4f5a1f5..f27233f 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 @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Set; +import static com.jnhprojects.mcheli.content.DefinitionValues.hex; import static com.jnhprojects.mcheli.content.DefinitionValues.lower; import static com.jnhprojects.mcheli.content.DefinitionValues.split; import static com.jnhprojects.mcheli.content.DefinitionValues.splitSlash; @@ -53,6 +54,10 @@ public abstract class AircraftDefinition extends Definition { public boolean smoothShading = true; public boolean hideEntity; public float particlesScale = 1.0F; + /** {@code EnableSeaSurfaceParticle}: spray off the water around a boat or amphibian under way. */ + public boolean seaSurfaceParticles; + public final List splashes = new ArrayList<>(); + public final List searchLights = new ArrayList<>(); // Durability public int maxHp = 50; @@ -130,6 +135,10 @@ public abstract class AircraftDefinition extends Definition { public boolean entityRadar; public boolean ejectionSeat; public boolean parachuting; + /** {@code MobDropOption}: where passengers leave under a parachute, and the ticks between two. */ + public Vector3 dropPosition = Vector3.ZERO; + public int dropInterval = 1; + public final List repellingHooks = new ArrayList<>(); public float entityWidth = 1.0F; public float entityHeight = 1.0F; public float entityPitch; @@ -320,6 +329,22 @@ protected void registerKeys(KeyTable keys) { keys.put("SmoothShading", v -> smoothShading = toBool(v)); keys.put("HideEntity", v -> hideEntity = toBool(v)); keys.put("ParticlesScale", v -> particlesScale = toFloat(v, 0.0F, 50.0F)); + keys.put("EnableSeaSurfaceParticle", v -> seaSurfaceParticles = toBool(v)); + keys.put("AddParticleSplash", v -> { + String[] s = split(v); + if (s.length >= 3) { + splashes.add(new ParticleSplash(toVec(s[0], s[1], s[2]), + s.length >= 4 ? toInt(s[3], 1, 100) : 2, + s.length >= 5 ? toFloat(s[4]) : 2.0F, + s.length >= 6 ? toFloat(s[5]) : 1.0F, + s.length >= 7 ? toInt(s[6], 1, 100000) : 80, + s.length >= 8 ? toFloat(s[7]) : 0.01F, + s.length >= 9 ? toFloat(s[8]) : 0.0F)); + } + }); + keys.put("AddSearchLight", v -> addSearchLight(v, false, false)); + keys.put("AddFixedSearchLight", v -> addSearchLight(v, true, false)); + keys.put("AddSteeringSearchLight", v -> addSearchLight(v, true, true)); // ItemID only mattered before 1.7 removed numeric ids; accept it so it is not reported. keys.put("ItemID", v -> { }); @@ -436,6 +461,19 @@ protected void registerKeys(KeyTable keys) { keys.put("EnableEntityRadar", v -> entityRadar = toBool(v)); keys.put("EnableEjectionSeat", v -> ejectionSeat = toBool(v)); keys.put("EnableParachuting", v -> parachuting = toBool(v)); + keys.put("MobDropOption", v -> { + String[] s = split(v); + if (s.length >= 3) { + dropPosition = toVec(s[0], s[1], s[2]); + dropInterval = s.length >= 4 ? Math.max(toInt(s[3]), 1) : 12; + } + }); + keys.put("AddRepellingHook", v -> { + String[] s = split(v); + if (s.length >= 3) { + repellingHooks.add(new RepellingHook(toVec(s[0], s[1], s[2]), s.length >= 4 ? toInt(s[3], 1, 100000) : 10)); + } + }); keys.put("EntityWidth", v -> entityWidth = toFloat(v, -100.0F, 100.0F)); keys.put("EntityHeight", v -> entityHeight = toFloat(v, -100.0F, 100.0F)); keys.put("EntityPitch", v -> entityPitch = toFloat(v, -360.0F, 360.0F)); @@ -848,6 +886,15 @@ private void parseBoundingBox(String data) { markerWidth = (float) Math.max(markerWidth, Math.abs(offset.z() - halfWidth) / 2.0); } + private void addSearchLight(String v, boolean fixed, boolean steering) { + String[] s = split(v); + if (s.length >= 7) { + searchLights.add(new SearchLight(toVec(s[0], s[1], s[2]), hex(s[3]), hex(s[4]), toFloat(s[5]), toFloat(s[6]), fixed, + s.length >= 8 ? toFloat(s[7]) : 0.0F, s.length >= 9 ? toFloat(s[8]) : 0.0F, steering, + s.length >= 10 ? toFloat(s[9]) : 0.0F)); + } + } + @Override protected void validate() throws InvalidDefinitionException { if (cameraPositions.isEmpty()) { @@ -856,6 +903,11 @@ protected void validate() throws InvalidDefinitionException { if (targetDrone) { uav = true; } + // Ropes and parachute drops are one or the other; with both, the original kept neither. + if (parachuting && !repellingHooks.isEmpty()) { + parachuting = false; + repellingHooks.clear(); + } if (uav) { alwaysCameraView = true; if (seats.isEmpty()) { diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/ParticleSplash.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/ParticleSplash.java new file mode 100644 index 0000000..146f317 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/ParticleSplash.java @@ -0,0 +1,10 @@ +package com.jnhprojects.mcheli.content.aircraft; + +import com.jnhprojects.mcheli.content.Vector3; + +/** + * {@code AddParticleSplash}: spray thrown up from {@code position} while it moves through water, + * {@code count} puffs a tick of {@code size}, pushed back by the speed times {@code acceleration}. + */ +public record ParticleSplash(Vector3 position, int count, float size, float acceleration, int age, float motionY, float gravity) { +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RepellingHook.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RepellingHook.java new file mode 100644 index 0000000..819a121 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RepellingHook.java @@ -0,0 +1,10 @@ +package com.jnhprojects.mcheli.content.aircraft; + +import com.jnhprojects.mcheli.content.Vector3; + +/** + * {@code AddRepellingHook}: where a rope hangs from a hovering helicopter, and how many ticks pass + * between two passengers sliding down it. + */ +public record RepellingHook(Vector3 position, int interval) { +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/SearchLight.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/SearchLight.java new file mode 100644 index 0000000..9144c9a --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/SearchLight.java @@ -0,0 +1,14 @@ +package com.jnhprojects.mcheli.content.aircraft; + +import com.jnhprojects.mcheli.content.Vector3; + +/** + * {@code AddSearchLight}, {@code AddFixedSearchLight} and {@code AddSteeringSearchLight}: a cone of + * light from {@code position}, fading from {@code colorStart} to {@code colorEnd} (both ARGB) over + * {@code height} blocks to a disc {@code width} across. A plain search light points where the + * gunner, or else the pilot, looks; a fixed one points along the vehicle, turned by {@code yaw} and + * {@code pitch}, and a steering one also turns with the front wheels. + */ +public record SearchLight(Vector3 position, int colorStart, int colorEnd, float height, float width, boolean fixed, + float yaw, float pitch, boolean steering, float steeringRotation) { +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java index 25d9e5a..5b47de9 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java @@ -44,6 +44,12 @@ private LegacyRecipe() { * loader ignores the other two. */ public static final List INGREDIENT_TYPE_KEYS = List.of("type", "neoforge:ingredient_type", "fabric:type"); + /** + * Equipment the mod registers as plain items, which a vehicle recipe may use as they are: the + * E-767 needs a UAV station. + */ + public static final Set EQUIPMENT_ITEMS = Set.of("mcheli:uav_station", "mcheli:uav_station2", "mcheli:gltd", + "mcheli:rangefinder", "mcheli:container", "mcheli:chain", "mcheli:parachute", "mcheli:fuel", "mcheli:wrench"); /** * @param result the crafted vehicle @@ -201,6 +207,9 @@ private static String model(Vehicle result, ImportTarget target) { private static String ingredient(String name, int damage, Predicate modItems, ImportTarget target) { String lower = name.trim().toLowerCase(Locale.ROOT); if (lower.startsWith("mcheli:")) { + if (EQUIPMENT_ITEMS.contains(lower)) { + return '"' + lower + '"'; + } if (!modItems.test(lower)) { throw new InvalidRecipeException(lower + " does not exist (yet)"); } diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/throwable/ThrowableDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/throwable/ThrowableDefinition.java new file mode 100644 index 0000000..d693595 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/throwable/ThrowableDefinition.java @@ -0,0 +1,90 @@ +package com.jnhprojects.mcheli.content.throwable; + +import com.jnhprojects.mcheli.content.Definition; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static com.jnhprojects.mcheli.content.DefinitionValues.split; +import static com.jnhprojects.mcheli.content.DefinitionValues.toBool; +import static com.jnhprojects.mcheli.content.DefinitionValues.toFloat; +import static com.jnhprojects.mcheli.content.DefinitionValues.toInt; + +/** + * A hand thrown item from {@code assets/mcheli/throwable}, such as the M18 smoke grenades. Ported + * from {@code MCH_ThrowableInfo}, keeping its keys, defaults and ranges. Times are read in seconds + * and kept in ticks, as {@code checkData} did. + */ +public final class ThrowableDefinition extends Definition { + public static final String FOLDER = "throwable"; + + public String displayName; + public int stackSize = 1; + public float dispenseAcceleration = 1.0F; + /** Explosion strength when the fuse runs out; 0 for a smoke grenade. */ + public int explosion; + /** Ticks until it explodes, or starts to smoke. */ + public int timeFuse; + /** Ticks until it disappears. */ + public int aliveTime = 200; + /** How much of its speed it keeps when it bounces off a block. */ + public float bound = 0.2F; + public float gravity; + public float gravityInWater = -0.04F; + public float smokeSize = 10.0F; + /** Smoke puffs a tick once the fuse has run out. */ + public int smokeNum; + public float smokeVelocityVertical = 1.0F; + public float smokeVelocityHorizontal = 1.0F; + /** Smoke colour, {@code 0xRRGGBB}; white by default. */ + public int smokeColor = 0xFFFFFF; + public boolean shapedRecipe = true; + public final List recipes = new ArrayList<>(); + + public ThrowableDefinition(String name) { + super(name); + this.displayName = name; + } + + @Override + protected void registerKeys(KeyTable keys) { + keys.put("DisplayName", v -> displayName = v.trim()); + // The importer writes the lang files from these two; nothing to keep here. + keys.put(v -> { }, "AddDisplayName", "ItemID"); + keys.put("StackSize", v -> stackSize = toInt(v, 1, 64)); + keys.put("DispenseAcceleration", v -> dispenseAcceleration = toFloat(v, 0.0F, 1000.0F)); + keys.put("Explosion", v -> explosion = toInt(v, 0, 50)); + keys.put("TimeFuse", v -> timeFuse = toInt(v, 0, 100000) * 20); + keys.put("AliveTime", v -> aliveTime = toInt(v, 0, 1000000) * 20); + keys.put("Bound", v -> bound = toFloat(v, 0.0F, 100000.0F)); + keys.put("Gravity", v -> gravity = toFloat(v, -50.0F, 50.0F)); + keys.put("GravityInWater", v -> gravityInWater = toFloat(v, -50.0F, 50.0F)); + keys.put("SmokeSize", v -> smokeSize = toFloat(v, 0.0F, 1000.0F)); + keys.put("SmokeNum", v -> smokeNum = toInt(v, 0, 1000)); + keys.put("SmokeVelocityVertical", v -> smokeVelocityVertical = toFloat(v, -100.0F, 100.0F)); + keys.put("SmokeVelocityHorizontal", v -> smokeVelocityHorizontal = toFloat(v, 0.0F, 1000.0F)); + keys.put("SmokeColor", v -> { + String[] s = split(v); + if (s.length >= 3) { + smokeColor = toInt(s[0], 0, 255) << 16 | toInt(s[1], 0, 255) << 8 | toInt(s[2], 0, 255); + } + }); + keys.put("AddRecipe", v -> { + shapedRecipe = true; + recipes.add(v.toUpperCase(Locale.ROOT)); + }); + keys.put("AddShapelessRecipe", v -> { + shapedRecipe = false; + recipes.add(v.toUpperCase(Locale.ROOT)); + }); + // Read by the original but never used by a throwable: they only made sense for weapons. + keys.put(v -> { }, "Power", "Acceleration", "AccelerationInWater", "DelayFuse", "Flaming", "SoundVolume", + "SoundPitch", "ProximityFuseDist", "Accuracy", "Bomblet", "BombletDiff", "Particle", "DisableSmoke"); + } + + /** {@code true} for a grenade that only smokes, like the M18s. */ + public boolean smokes() { + return explosion <= 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 06b6014..d3ce978 100644 --- a/common/src/main/resources/assets/mcheli/lang/en_us.json +++ b/common/src/main/resources/assets/mcheli/lang/en_us.json @@ -17,17 +17,55 @@ "entity.mcheli.at_missile": "Anti-Tank Missile", "entity.mcheli.bomb": "Bomb", "entity.mcheli.bullet": "Bullet", + "entity.mcheli.chain": "Tow Chain", + "entity.mcheli.container": "Container", "entity.mcheli.dispensed_item": "Dispensed Canister", "entity.mcheli.flare": "Flare", + "entity.mcheli.gltd": "GLTD", "entity.mcheli.helicopter": "Helicopter", "entity.mcheli.marker_rocket": "Marker Rocket", "entity.mcheli.parachute": "Parachute", "entity.mcheli.plane": "Plane", + "entity.mcheli.rappel": "Rope", "entity.mcheli.rocket": "Rocket", "entity.mcheli.tank": "Tank", + "entity.mcheli.throwable": "Grenade", "entity.mcheli.torpedo": "Torpedo", "entity.mcheli.tv_missile": "TV-Guided Missile", + "entity.mcheli.uav_station": "UAV Station", "entity.mcheli.vehicle": "Ground Vehicle", + "gui.mcheli.drafting_table.create": "Create", + "gui.mcheli.drafting_table.empty": "No recipes in this list", + "gui.mcheli.drafting_table.fuel": "Fuel: %s", + "gui.mcheli.drafting_table.hp": "Hit points: %s", + "gui.mcheli.drafting_table.list.helicopter": "Helicopters", + "gui.mcheli.drafting_table.list.item": "Equipment", + "gui.mcheli.drafting_table.list.plane": "Planes", + "gui.mcheli.drafting_table.list.tank": "Tanks", + "gui.mcheli.drafting_table.list.vehicle": "Vehicles", + "gui.mcheli.drafting_table.next_page": "Next Page", + "gui.mcheli.drafting_table.previous_page": "Prev Page", + "gui.mcheli.drafting_table.seats": "Seats: %s", + "gui.mcheli.drafting_table.speed": "Speed: %s", + "gui.mcheli.drafting_table.weapons": "Weapons:", + "gui.mcheli.team.color": "Colour: %s", + "gui.mcheli.team.create": "Create team", + "gui.mcheli.team.destroy_all": "Destroy all vehicles", + "gui.mcheli.team.destroy_all_hint": "Removes every loaded helicopter, plane, tank and vehicle", + "gui.mcheli.team.friendly_fire_off": "Friendly fire: off", + "gui.mcheli.team.friendly_fire_on": "Friendly fire: on", + "gui.mcheli.team.jump_spawn": "Everyone to spawn", + "gui.mcheli.team.jump_spawn_hint": "Sends every player to their respawn point", + "gui.mcheli.team.name": "Team name", + "gui.mcheli.team.new_team": "New team", + "gui.mcheli.team.none": "No teams yet", + "gui.mcheli.team.pvp": "Switch PvP", + "gui.mcheli.team.shuffle": "Shuffle teams", + "gui.mcheli.team.shuffle_hint": "Puts every player online into the teams at random", + "gui.mcheli.team.title": "MCHeli Team Manager", + "gui.mcheli.uav_station.continue": "Continue", + "gui.mcheli.uav_station.not_uav": "Not a UAV", + "gui.mcheli.uav_station.small_only": "Small UAVs only", "gui.mcheli.vehicle.cargo": "Cargo", "gui.mcheli.vehicle.fuel": "Fuel %s%%", "gui.mcheli.vehicle.no_weapons": "No weapons", @@ -40,17 +78,29 @@ "hud.mcheli.flare_active": "Flares away", "hud.mcheli.flare_ready": "Flares ready [%s]", "hud.mcheli.flare_wait": "Flares reloading", + "hud.mcheli.gltd.ready": "A-10 on call: use to mark the target", + "hud.mcheli.gltd.wait": "A-10 rearming, %s s", "hud.mcheli.hp": "HP %s / %s", + "hud.mcheli.launcher.direct": "Mode: direct", + "hud.mcheli.launcher.empty": "Empty: lower it with a missile in your inventory to reload", + "hud.mcheli.launcher.reloading": "Reloading", + "hud.mcheli.launcher.top_attack": "Mode: top attack (40 m or more)", "hud.mcheli.locked": "LOCKED", "hud.mcheli.locking": "Locking %s%%", "hud.mcheli.no_weapon": "No weapon for this seat", "hud.mcheli.overheat": "OVERHEAT", "hud.mcheli.preparing": "Preparing", + "hud.mcheli.rangefinder.empty": "Empty: craft with redstone", + "hud.mcheli.rangefinder.mark_point": "Mark point", + "hud.mcheli.rangefinder.mobs": "Monsters and mobs", + "hud.mcheli.rangefinder.players_and_vehicles": "Players and vehicles", "hud.mcheli.reloading": "Reloading", "hud.mcheli.resupply": "Empty, reload on the ground [%s]", + "hud.mcheli.zoom": "Zoom x%s", "item.mcheli.helicopter": "Helicopter", "item.mcheli.plane": "Plane", "item.mcheli.tank": "Tank", + "item.mcheli.throwable": "Grenade", "item.mcheli.vehicle": "Ground Vehicle", "itemGroup.mcheli.equipment": "MCHeli Equipment", "itemGroup.mcheli.helicopters": "MCHeli Helicopters", @@ -59,6 +109,7 @@ "itemGroup.mcheli.vehicles": "MCHeli Ground Vehicles", "key.category.mcheli.main": "MCHeli", "key.mcheli.camera_mode": "Night Vision / Thermal", + "key.mcheli.drop_crew": "Drop Troops / Jump Out", "key.mcheli.flare": "Flares", "key.mcheli.fold": "Zoom / Fold Wings / Hatch", "key.mcheli.free_look": "Free Look", @@ -68,11 +119,19 @@ "key.mcheli.switch_camera": "Camera / Gunner Sight", "key.mcheli.switch_seat": "Switch Seat", "key.mcheli.switch_weapon": "Switch Weapon", + "key.mcheli.team_manager": "Team Manager", "key.mcheli.vehicle_screen": "Vehicle Screen", "key.mcheli.vtol": "VTOL Mode", "key.mcheli.weapon_mode": "Weapon Mode", + "message.mcheli.chain.connected": "%s chained to %s, which tows it", + "message.mcheli.chain.hooked": "Chain hooked to %s: now use it on the other end, 2 to 16 blocks away", + "message.mcheli.chain.no_vehicle": "One end of the chain must be a vehicle to pull it", + "message.mcheli.chain.released": "Chain let go", + "message.mcheli.chain.removed": "Chain taken off", + "message.mcheli.chain.too_close": "Too close to tow (%s blocks): at least 2 blocks apart", "message.mcheli.content.import_failed": "MCHeli could not import the original vehicles: %s. See logs/latest.log.", "message.mcheli.content.missing": "MCHeli has no vehicles yet. Download %s from the MCHeli CurseForge page, put it in %s and restart the game.", + "message.mcheli.mark_point": "%s marked %s %s %s", "message.mcheli.no_parachute": "No parachute in the vehicle: put one in its parachute slot first", "message.mcheli.pack.downloading": "MCHeli: downloading %s content packs (%s MB) from the server", "message.mcheli.pack.failed": "MCHeli: content pack %s could not be downloaded: %s", @@ -80,6 +139,16 @@ "message.mcheli.pack.problem": "MCHeli content pack %s: %s", "message.mcheli.pack.ready": "MCHeli: %s content packs downloaded and ready", "message.mcheli.pack.too_large": "MCHeli: content pack %s is %s MB, more than your cache allows (packs.cacheSize in mcheli-client.toml, %s MB). Its vehicles stay invisible.", + "message.mcheli.team.bad_name": "Cannot make a team called \"%s\": use 1 to 16 characters, no spaces, and a name not taken", + "message.mcheli.team.created": "Team %s created", + "message.mcheli.team.destroyed": "%s vehicles removed", + "message.mcheli.team.no_teams": "There are no teams to shuffle into", + "message.mcheli.team.operators_only": "Only operators can open the team manager", + "message.mcheli.team.pvp_off": "PvP is now off", + "message.mcheli.team.pvp_on": "PvP is now on", + "message.mcheli.team.shuffled": "Players shuffled into %s teams", + "message.mcheli.uav.blocked": "There is no room to launch the UAV there; change the launch position", + "message.mcheli.uav.lost": "UAV out of range: the link is lost", "message.mcheli.vehicle.disabled": "%s is disabled on this server", "message.mcheli.vehicle.unknown": "This server has no vehicle called %s", "pack.mcheli.content": "MCHeli Content", 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 181f726..5b7517f 100644 --- a/common/src/main/resources/assets/mcheli/lang/ja_jp.json +++ b/common/src/main/resources/assets/mcheli/lang/ja_jp.json @@ -1,9 +1,47 @@ { + "entity.mcheli.chain": "牽引チェーン", + "entity.mcheli.container": "コンテナ", + "entity.mcheli.gltd": "GLTD", "entity.mcheli.helicopter": "ヘリコプター", "entity.mcheli.parachute": "パラシュート", "entity.mcheli.plane": "飛行機", + "entity.mcheli.rappel": "ロープ", "entity.mcheli.tank": "戦車", + "entity.mcheli.throwable": "手榴弾", + "entity.mcheli.uav_station": "UAVステーション", "entity.mcheli.vehicle": "地上兵器", + "gui.mcheli.drafting_table.create": "作成", + "gui.mcheli.drafting_table.empty": "このリストにレシピはありません", + "gui.mcheli.drafting_table.fuel": "燃料: %s", + "gui.mcheli.drafting_table.hp": "耐久力: %s", + "gui.mcheli.drafting_table.list.helicopter": "ヘリコプター", + "gui.mcheli.drafting_table.list.item": "装備", + "gui.mcheli.drafting_table.list.plane": "飛行機", + "gui.mcheli.drafting_table.list.tank": "戦車", + "gui.mcheli.drafting_table.list.vehicle": "車両", + "gui.mcheli.drafting_table.next_page": "次のページ", + "gui.mcheli.drafting_table.previous_page": "前のページ", + "gui.mcheli.drafting_table.seats": "座席: %s", + "gui.mcheli.drafting_table.speed": "速度: %s", + "gui.mcheli.drafting_table.weapons": "武装:", + "gui.mcheli.team.color": "色: %s", + "gui.mcheli.team.create": "チームを作成", + "gui.mcheli.team.destroy_all": "全機体を消去", + "gui.mcheli.team.destroy_all_hint": "読み込まれているヘリ、飛行機、戦車、車両をすべて消去します", + "gui.mcheli.team.friendly_fire_off": "同士討ち: オフ", + "gui.mcheli.team.friendly_fire_on": "同士討ち: オン", + "gui.mcheli.team.jump_spawn": "全員をスポーン地点へ", + "gui.mcheli.team.jump_spawn_hint": "全プレイヤーをリスポーン地点へ移動します", + "gui.mcheli.team.name": "チーム名", + "gui.mcheli.team.new_team": "新しいチーム", + "gui.mcheli.team.none": "チームはまだありません", + "gui.mcheli.team.pvp": "PvP 切り替え", + "gui.mcheli.team.shuffle": "チームをシャッフル", + "gui.mcheli.team.shuffle_hint": "オンラインの全プレイヤーをランダムにチームへ振り分けます", + "gui.mcheli.team.title": "MCHeli チーム管理", + "gui.mcheli.uav_station.continue": "再接続", + "gui.mcheli.uav_station.not_uav": "UAVではありません", + "gui.mcheli.uav_station.small_only": "小型UAVのみ", "gui.mcheli.vehicle.cargo": "積荷", "gui.mcheli.vehicle.fuel": "燃料 %s%%", "gui.mcheli.vehicle.no_weapons": "武装なし", @@ -13,6 +51,7 @@ "item.mcheli.helicopter": "ヘリコプター", "item.mcheli.plane": "飛行機", "item.mcheli.tank": "戦車", + "item.mcheli.throwable": "手榴弾", "item.mcheli.vehicle": "地上兵器", "itemGroup.mcheli.equipment": "MCHeli 装備", "itemGroup.mcheli.helicopters": "MCHeli ヘリコプター", @@ -21,6 +60,7 @@ "itemGroup.mcheli.vehicles": "MCHeli 地上兵器", "key.category.mcheli.main": "MCHeli", "key.mcheli.camera_mode": "暗視 / 熱線映像", + "key.mcheli.drop_crew": "降下 / 飛び降り", "key.mcheli.fold": "ズーム / 翼・ハッチの折りたたみ", "key.mcheli.free_look": "フリールック", "key.mcheli.landing_gear": "着陸脚", @@ -28,15 +68,33 @@ "key.mcheli.rack_unload": "ラックから降ろす / 離脱", "key.mcheli.switch_camera": "カメラ / 照準切り替え", "key.mcheli.switch_seat": "座席切り替え", + "key.mcheli.team_manager": "チーム管理", "key.mcheli.vehicle_screen": "機体画面", "key.mcheli.vtol": "VTOLモード", + "message.mcheli.chain.connected": "%s を %s で牽引します", + "message.mcheli.chain.hooked": "%s にチェーンを掛けました。2〜16ブロック離れたもう一方に使ってください", + "message.mcheli.chain.no_vehicle": "チェーンの片方は牽引する機体である必要があります", + "message.mcheli.chain.released": "チェーンを外しました", + "message.mcheli.chain.removed": "チェーンを取り外しました", + "message.mcheli.chain.too_close": "近すぎます (%s ブロック)。2ブロック以上離してください", "message.mcheli.content.import_failed": "MCHeli: 元の機体データを読み込めませんでした: %s。logs/latest.log を確認してください。", "message.mcheli.content.missing": "MCHeli: 機体データがありません。MCHeli の CurseForge ページから %s をダウンロードし、%s に置いてからゲームを再起動してください。", + "message.mcheli.mark_point": "%s がマーク: %s %s %s", "message.mcheli.no_parachute": "機体にパラシュートがありません。先にパラシュート欄に入れてください", "message.mcheli.pack.downloading": "MCHeli: サーバーからコンテンツパックを %s 個 (%s MB) ダウンロードしています", "message.mcheli.pack.failed": "MCHeli: コンテンツパック %s をダウンロードできませんでした: %s", "message.mcheli.pack.ready": "MCHeli: コンテンツパック %s 個のダウンロードが完了しました", "message.mcheli.pack.too_large": "MCHeli: コンテンツパック %s は %s MB で、キャッシュの上限 (mcheli-client.toml の packs.cacheSize、%s MB) を超えています。このパックの機体は表示されません。", + "message.mcheli.team.bad_name": "「%s」というチームは作れません。空白なしの1〜16文字で、使われていない名前にしてください", + "message.mcheli.team.created": "チーム %s を作成しました", + "message.mcheli.team.destroyed": "%s 機を消去しました", + "message.mcheli.team.no_teams": "振り分けるチームがありません", + "message.mcheli.team.operators_only": "チーム管理を開けるのはオペレーターだけです", + "message.mcheli.team.pvp_off": "PvP をオフにしました", + "message.mcheli.team.pvp_on": "PvP をオンにしました", + "message.mcheli.team.shuffled": "プレイヤーを %s チームに振り分けました", + "message.mcheli.uav.blocked": "そこにはUAVを出せません。発進位置を変えてください", + "message.mcheli.uav.lost": "UAVが範囲外に出たため通信が途絶しました", "pack.mcheli.content": "MCHeli コンテンツ", "pack.mcheli.content.description": "EMB4 氏の MCHeli 1.0.3 から読み込み" } diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java index 8e578bc..b4253af 100644 --- a/common/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java @@ -38,7 +38,7 @@ void producesThe26xLayout() throws IOException { assertTrue(Files.isRegularFile(assets.resolve("models/item/ah-64.json")), "item model"); assertTrue(Files.readString(assets.resolve("sounds.json")).contains("\"wrench\": {\n \"sounds\": [\"mcheli:wrench1\""), "numbered sound files share one event"); - assertEquals(108, countFiles(TestResources.pack().resolve("data/mcheli/recipe"))); + assertEquals(109, countFiles(TestResources.pack().resolve("data/mcheli/recipe"))); } /** Minecraft merges lang files across packs, so the import and the jar must not fight over a key. */ diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java index 0d4f911..f808c95 100644 --- a/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java @@ -9,6 +9,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * The crafting recipes are generated from the definitions' {@code AddRecipe} lines when the @@ -24,9 +25,10 @@ static void generate() throws IOException { } @Test - void everyRecipeConvertsExceptTheKnownGaps() { - assertEquals(List.of("e767: mcheli:uav_station does not exist (yet)"), generated.skipped().stream().sorted().toList()); - assertEquals(108, generated.files().size()); + void everyRecipeConverts() { + assertEquals(List.of(), generated.skipped()); + assertEquals(109, generated.files().size()); + assertTrue(generated.files().get("e767.json").contains("\"Y\": \"mcheli:uav_station\"")); } @Test diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/ThrowableDefinitionTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/ThrowableDefinitionTest.java new file mode 100644 index 0000000..0464aec --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/ThrowableDefinitionTest.java @@ -0,0 +1,69 @@ +package com.jnhprojects.mcheli.content; + +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ThrowableDefinitionTest { + + /** The shape of the original M18 files, written here so the test runs without the import. */ + private static final String SMOKE = """ + DisplayName = Smoke (Red) + AddDisplayName = ja_JP, Smoke + ItemID = 28902 + StackSize = 1 + AliveTime = 30 + Explosion = 0 + TimeFuse = 2 + Bound = 0.3 + Gravity = -0.04 + GravityInWater = 0.01 + SmokeSize = 10 + SmokeNum = 6 + SmokeColor = 180, 30, 30 + SmokeVelocityVertical = 0.7 + SmokeVelocityHorizontal = 0.7 + AddRecipe = "DY", Y, iron_ingot, D,dye,1 + DispenseAcceleration = 1.1 + """; + + @Test + void readsSmokeGrenade(@TempDir Path dir) throws IOException { + Path file = dir.resolve("m18_red.txt"); + Files.writeString(file, SMOKE); + + DefinitionReader.Result result = DefinitionReader.read(file, new ThrowableDefinition("m18_red")); + assertTrue(result.isValid()); + assertTrue(result.unhandledKeys().isEmpty(), () -> "unhandled " + result.unhandledKeys()); + ThrowableDefinition smoke = result.definition(); + + assertEquals("Smoke (Red)", smoke.displayName); + assertTrue(smoke.smokes()); + // Seconds in the file, ticks in the definition. + assertEquals(40, smoke.timeFuse); + assertEquals(600, smoke.aliveTime); + assertEquals(0.3F, smoke.bound, 1.0E-6); + assertEquals(-0.04F, smoke.gravity, 1.0E-6); + assertEquals(6, smoke.smokeNum); + assertEquals(0xB41E1E, smoke.smokeColor); + assertEquals(1, smoke.recipes.size()); + } + + @Test + void registryLoadsThrowableFolder(@TempDir Path assets) throws IOException { + Files.createDirectories(assets.resolve(ThrowableDefinition.FOLDER)); + Files.writeString(assets.resolve(ThrowableDefinition.FOLDER).resolve("m18_red.txt"), SMOKE); + + ContentRegistry registry = ContentRegistry.load(assets); + assertNotNull(registry.throwable("m18_red")); + assertEquals(1, registry.throwables().size()); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a70b107..a07b0e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -267,8 +267,9 @@ following `MCH_ItemRecipe`. `LegacyItems` maps the 1.7.10 item names and damage wool by colour, the common `c:` tags where an item became a family (iron, glass, dyes) so other mods' materials count, vanilla tags for logs, planks and boats. As in 1.7.10, a pattern letter the key does not define is an empty slot. `RecipeDataTest` converts the whole set and spot checks -the output. `CommonHooks.playerLoggedIn` puts every MCHeli recipe in a player's recipe book on login, standing in -for the drafting table's recipe list. +the output. `CommonHooks.playerLoggedIn` puts every MCHeli recipe in a player's recipe book on login, which +is where the drafting table lists them from. Recipe ingredients may also name the equipment items +(`LegacyRecipe.EQUIPMENT_ITEMS`): the E-767 needs a UAV station. ## Models @@ -488,6 +489,74 @@ player, steered by the rider's forward input from `ServerPlayer.getLastClientInp `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. +### Placed equipment, throwables and spotting + +Equipment that lives in the world is an entity set up by `item.PlacedEntityItem`: `GltdEntity`, +`ContainerEntity` and `UavStationEntity` (whose item sets its kind and facing first). Each is +server simulated and eased on clients with a `LinearInterpolationHandler`. + +Throwables are content: `content.throwable.ThrowableDefinition` in `common`, loaded from the +`throwable` folder of every source. Like the vehicles there is one item, `mcheli:throwable`, whose +`mcheli:throwable` data component names the definition, so packs add throwables without new +items. `ThrowableEntity` flies and bounces on the server; clients spawn the `mcheli:smoke` +particle (`SmokeParticleOptions`, coloured, using vanilla's campfire smoke sprites). + +The GLTD calls a `weapon.CasStrike`, the three-pass A-10 run that vehicles with a CAS weapon also +use through `ServerFiring`. Its sight reuses `VehicleCamera`'s zoom steps and camera modes; the +client asks for strikes and night vision with `EquipmentActionPacket`, which equipment checks +against the rider like `CrewActionPacket`. Night vision for any sight goes through +`entity.NightVision`. + +`weapon.Spotting` is the server half of the rangefinder (`SpotPacket`) and of targeting pods: a +40 degree cone out to 400 blocks, line of sight, never the spotter's allies (`isAlliedTo`). +Nothing on the target changes. The spotted ids, or a marked block, go to the spotter and their +`team.TeamManager.teammates` in a `MarkerPacket`; its handler fills `team.Markers` (plain shared +code, so the packet class stays loader and side neutral) and `client.TargetMarkers` draws them on +the HUD with `GameRenderer.projectPointToScreen`, falling back to a diamond at the screen edge +for points behind the camera or off screen. + +`entity.UavStationEntity` is the UAV station and portable controller in one entity, with a kind. +Its one slot is `menu.UavStationMenu`; the launch offset buttons and Continue are container +button clicks. The original kept the pilot at the station and let the drone read its controls +from there. Movement here is the vanilla vehicle sync, which only runs for a vehicle's own +passengers, so the pilot rides the drone's reserved seat 0 instead, hidden and permanently +invulnerable, tagged `mcheli_remote_pilot`. The drone keeps the station's UUID and return point +(saved), checks the 123 block range each tick, and on dismount sends the pilot back through +`getDismountLocationForPassenger`. `CommonHooks.playerLoggedIn` undoes a stale tag, and a saved +seat lets the pilot back into seat 0 after a reload. + +Troops: `AircraftEntity.boardNearbyMobs` seats mobs when the pilot boards. `dropCrew` (the pilot's +`DROP_CREW` action) starts ropes or a parachute drop; `jumpOut` is a passenger's own way out. A +rope descent is an `entity.RappelEntity` (the original's `MCH_EntityHide`) that the rider sits on; +it follows its hook under the helicopter and lets go at the ground or the rope's end. The ropes +themselves are drawn by `AircraftRenderer` from the synced repelling flag and a rope length both +sides let out at their own pace, as in the original. + +`HelicopterEntity` folds its blades from a synced fold state (folded, folding, unfolded, +unfolding); each side steps every blade towards the tail or back to the spinning rotor itself, and +the renderer reads per blade angles. Search lights are an additive cone (`Glow.cone`) per lamp in +the vehicle's frame. `entity.VehicleParticles` holds the client side downwash, spray, nozzle and +landing gear particles, using `mcheli:smoke` with a lifetime. + +The drafting table is the mod's one block: `block.DraftingTableBlock` with an eight way rotation +and a lamp, an invisible block model and a block entity whose `DraftingTableRenderer` draws the +original MQO. The item uses a small cuboid model made of vanilla textures, since the jar may not +carry the original's art. A 26.x client only knows the recipe book, not the server's recipes, so +`DraftingTableScreen` lists the MCHeli entries of the player's recipe book and sends the chosen +`RecipeDisplayId` in a `DraftingTablePacket`; `DraftingTableMenu.craft` resolves it through +`RecipeManager.getRecipeFromDisplay`, checks and takes one stack per ingredient (so the vehicle +ingredient's component check applies), and assembles the result into the output slot. + +`team.TeamManager` is the operators' team manager: vanilla scoreboard teams, the `pvp` game rule, +respawn points and a sweep of loaded vehicles. `TeamPacket` carries the screen's buttons and the +server checks the gamemaster permission again. + + +Racks carry `ContainerEntity` as well as vehicles when their `AddRack` names `container`. Cargo +other than vehicles that leaves a rack with no ground within 20 blocks rides down on a +`ParachuteEntity` of kind `DROP`. `ChainEntity` keeps the towed end within its length of the end +that pulls; vehicles and containers hand chain clicks to `ChainItem` before boarding or opening. + ### Vehicle inventory and supply `entity.VehicleInventory` is the original's `MCH_AircraftInventory` plus the container half of diff --git a/docs/PLAYING.md b/docs/PLAYING.md new file mode 100644 index 0000000..68e9ce4 --- /dev/null +++ b/docs/PLAYING.md @@ -0,0 +1,227 @@ +# Playing MCHeli Rework + +How to fly, drive and fight, the equipment, and the commands and settings for servers. Installing +is covered in the [README](../README.md#installing). + +* [Getting started](#getting-started) +* [Controls](#controls) +* [Fuel, repairs and the vehicle screen](#fuel-repairs-and-the-vehicle-screen) +* [Equipment](#equipment) +* [Commands and server settings](#commands-and-server-settings) + +## Getting started + +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. + +In survival, vehicles and equipment are crafted with the original's recipes (mostly iron blocks +and ingots, glass, redstone and dyes), on a crafting table or on the [drafting table](#equipment), +which lists them all. They appear in the recipe book as soon as you join. A few vehicles had no +recipe in the original and are creative only: the M1129, M26, MXT-MV, fuel truck and ammo box. + +## Controls + +### Helicopters + +| Input | Action | +|-------|--------| +| Mouse | Pitch, bank and turn. The view follows the nose. | +| W / S | Throttle up / down | +| A / D | Bank left / right | +| Space | Toggle hover mode. Hover holds altitude; W/A/S/D then slide the helicopter. | +| Z | Fold or unfold the blades, on helicopters that can, with the engine stopped. Folded, A/D turn it on the ground. | + +Lift drops as the helicopter tilts, so pitch the nose down gently to pick up speed and level out +to climb. + +### Planes + +| Input | Action | +|-------|--------| +| Mouse | Pitch, roll and turn | +| 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. Through a sight, zoom instead. | +| V | VTOL mode, for planes that have it | +| Space, twice | Eject, for planes with ejection seats | + +### Tanks and cars + +| Input | Action | +|-------|--------| +| Mouse | Look around; the turret follows the view | +| W / S | Throttle; S brakes and then reverses where the vehicle can | +| A / D | Turn | +| Space (hold) | Hand brake | + +### Every seat + +| Input | Action | +|-------|--------| +| Left Alt (hold) | Free look. On tanks it swaps between looking around and looking along the hull. | +| H | Pilot: next camera position, or gunner mode on vehicles that have it (the autopilot flies while you man the guns). Gunners: toggle the gunner sight. | +| R | Move to the next free seat | +| J | Load nearby vehicles and containers onto your racks, or drive onto a carrier's rack | +| U | Unload the last vehicle or container 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, then thermal view, then back to normal, in vehicles that have night vision. In vehicles with search lights, pilot and first crew seat: lights on and off. | +| Y | Pilot: let the troops out (see [Troops](#troops)). Anyone else aboard: slide down a rope or jump with a parachute. | +| I | The vehicle screen: fuel and parachute slots, reloading, and the cargo chest | +| Shift | Leave the vehicle | + +### Weapons + +| Input | Action | +|-------|--------| +| Right mouse (hold) | Fire the selected weapon. Seeking missiles lock on while held and fire once locked. | +| Middle mouse or G | Next weapon for your seat; with Shift, the previous one | +| X | Fire mode, for weapons that have one (HE rounds, top attack, TV guidance) | +| C | Release flares (pilot and first crew seat) | + +Each seat fires the weapons its vehicle assigns to it; the pilot also fires a gunner's weapons +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, repairs and the vehicle screen + +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, or put cans in the fuel slots of the vehicle +screen (I), which empty into the tank while the vehicle is on the ground. Right-click in the air +with coal or charcoal in your inventory to fill a can again. A fuel truck tops up every vehicle +around it, and an ammunition truck rearms them. The **wrench** repairs 10 hit points per click, +once a second; with Shift it switches the vehicle's paint. + +The vehicle screen also reloads weapons from your own items, one supply per click, while the +vehicle is on the ground; closing it loads every magazine. Vehicles with cargo space have a +**Cargo** button there, and Shift + right-click opens their cargo chest from outside. What a +vehicle carries drops when it is destroyed. + +## Equipment + +Everything here is in the MCHeli Equipment tab and crafted with the original's recipes. + +**Parachute.** Use it in mid-air to open it; hold W to glide forward. Ejection seats need a +parachute in the vehicle screen's parachute slots, one per ejection. + +**Smoke grenades** (M18, in red, green, violet and yellow). Hold use to draw and let go to throw: +the longer the draw, the further it goes. They bounce, and after two seconds pour out coloured +smoke for half a minute, to mark a landing zone or a target. Dispensers throw them too. + +**GLTD target designator.** Place it on a block and right-click to sit behind it; Shift leaves. +The view moves to its sight, which turns up to 70 degrees either way. + +| Input | Action | +|-------|--------| +| Right mouse | Mark the spot under the cross: an A-10 makes three gun runs over it | +| Z | Zoom: 2x, 4x, 8x, then back | +| K | Night vision, then thermal view, then back to normal | + +The sight shows when the next A-10 can be called, about 22 seconds after the last one. + +**Rangefinder.** Hold use to raise it; after a moment the scope shows the range to whatever it +points at, in metres. + +| Input (scope up) | Action | +|-------|--------| +| Z / X (hold) | Zoom in / out, from 1.2x to 10x | +| C | What to spot: players and vehicles, monsters and mobs, or a point | +| Left mouse | Spot | + +Spotting marks every target of that kind in a narrow cone out to 400 blocks for 15 seconds +(mobs a minute), never your teammates, and uses one of ten charges. Only you and your team see the +marks: a triangle over each target with its distance, or a diamond at the screen edge pointing +the way. Marking a point puts a diamond on it for a minute and sends its coordinates to your team +in chat, and is free. Craft the rangefinder with redstone to +charge it again. + +**Container.** A crate with a large chest's worth of room: place it on a block, right-click to +open it. It floats and can be pushed. Vehicles whose racks take containers (the CH-47 and others) +load it with J and drop it with U; dropped with no ground in the 20 blocks below, it comes down +under a parachute. Punching it a few times breaks it and drops what is inside. + +**Stinger and Javelin.** Shoulder launched missiles: the FIM-92 Stinger locks onto aircraft, the +FGM-148 Javelin onto ground vehicles and players in vehicles. Hold use to raise the sight and keep +the target in the middle until the tone turns steady and the sight reads LOCKED. + +| Input (sight up) | Action | +|-------|--------| +| Left mouse | Fire at the locked target | +| Z | Zoom: the Javelin has two steps | +| X | Javelin: direct or top attack. Top attack climbs and dives onto the target, and needs 40 blocks of room. | + +Each launcher holds one missile. Lower an empty one with a missile in your inventory and it +reloads in two seconds. Missiles are crafted two at a time. + +**UAV station and portable UAV controller.** Place the station on a block and right-click to sit at +it; its screen has a slot for a drone and buttons that set where the drone appears, up to 50 +blocks out each way. Put a UAV in the slot and you fly it straight away, with its own controls, +HUD and weapons; the HUD shows how far the drone is and how strong the link is. Shift hands the +drone back and puts you beside the station, and **Continue** takes the last drone again. Past +123 blocks from the station the link breaks and the drone is lost. The portable controller works +the same but only launches small UAVs; Shift and right-click opens and closes its lid. Drones put +up from a station start with 5% fuel, target drones with a full tank, and those fly themselves. + +**Drafting table.** Right-click it to browse every MCHeli recipe, by list: +helicopters, planes, vehicles, tanks and equipment. Each page shows the recipe, and either the +original's illustrated description or the vehicle's hit points, speed, seats, fuel and weapons. +**Create** takes the ingredients from your inventory and puts the item in the output slot; creative +players need none. Shift and right-click switches its lamp. + +**Tow chain.** Right-click the two ends with the chain, in either order, 2 to 16 blocks apart. +One end must be a vehicle, which does the pulling: a truck can drag a container or a broken down +car, and a helicopter can lift a container as a sling load. Right-click either end again to take +the chain off. Each step shows a message above the hotbar. + +### Troops + +When the pilot climbs in, mobs standing close by (villagers, for instance) take the free crew +seats. The pilot's Y key lets them out: + +* Helicopters with rappelling hooks (the CH-47, MH-6, MH-60G and others) lower ropes when Y is + pressed in a steady, level hover. After three seconds the mobs slide down one by one; Y again pulls the + ropes in. Players in the back press Y to slide down a rope themselves. +* Vehicles with parachute drops open the hatch at least 10 blocks up and drop the mobs behind the + second seat one by one under parachutes. Players behind the second seat can jump with Y. +* Close to the ground the mobs simply get out. + +## Commands and server settings + +| Command | Who | What it does | +|---------|-----|--------------| +| `/mcheli` | everyone | Where the vehicles were loaded from, and how many of each kind | +| `/mcheli list [helicopter\|plane\|tank\|vehicle]` | everyone | The loaded vehicles by name | +| `/mcheli give [count]` | operators | Hands out vehicles, for example `/mcheli give @s ah-64` | +| `/mcheli packs` | everyone | The content packs in use, and any problems with them | +| `/mcheli reload` | operators | Reads the vehicle and weapon files and the content packs again without a restart, and sends players the new packs | + +Operators can run a match from the **team manager** (M): switch PvP, shuffle every player online +into the teams, make a team with a colour and friendly fire on or off, send everyone to their +spawn point, and remove every vehicle. The teams are the vanilla scoreboard teams, so `/team` and +the player list show the same ones, and only teammates see each other's spotted targets. + +Each world has `serverconfig/mcheli-server.toml` (created on first start, edits apply while the +server runs): + +| Setting | Default | Meaning | +|---------|---------|---------| +| `explosions.breakBlocks` | `true` | Weapons and crashes break blocks (the `mobGriefing` rule applies too) | +| `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 | +| `packs.maxTotalSize` | `64` | Most the server loads and sends in all, in MB | + +Each player also has `config/mcheli-client.toml`, which only changes what that player sees: + +| Setting | Default | Meaning | +|---------|---------|---------| +| `effects.nightGlow` | `true` | Tracers, rocket motors, flares and explosions glow: faintly by day, brightly at night, in caves and under water | +| `packs.cacheSize` | `256` | How much the content packs downloaded from servers may take up in `mcheli/cache`, in MB | + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a7cb408..01a0621 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -136,7 +136,7 @@ Rules that hold for every beta: | 7 | `1.1.0-beta.3` | Loaders (Forge, NeoForge, Fabric) and 1.21.1 | Released | | 8 | `1.1.0-beta.4` | Fuel, first equipment, hull hit boxes (Forge 26.3 only) | Released | | 9 | `1.1.0-beta.5` | Vehicle screen, cargo, supply vehicles, thermal view (Forge 26.3 only) | Released | -| 10 | `1.1.0-beta.6` | The rest of the equipment (Forge 26.3 only) | Planned | +| 10 | `1.1.0-beta.6` | The rest of the equipment (Forge 26.3 only) | Released | | 11 | `1.1.0-beta.7` | Workshop: model, parts and tuning (Forge 26.3 only) | Planned | | 12 | `1.1.0-beta.8` | Workshop: textures, sounds and sharing (Forge 26.3 only) | Planned | | 13 | `1.1.0-beta.9` | Every loader, 1.21.1 and 1.20.1 | Planned | @@ -258,13 +258,16 @@ Built and tested on Forge 26.3 only. What was not done in this phase moved to ph ## Phase 10: the rest of the equipment (`1.1.0-beta.6`) -- [ ] 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 -- [ ] Marking blocks with targeting pods, mortar distance -- [ ] The drafting table and its recipe browser (pack vehicles included) -- [ ] Multiplayer team manager and spotting +- [x] Equipment: GLTD, rangefinder, chain towing, container, smoke grenades +- [x] Equipment: Stinger and Javelin +- [x] Equipment: UAV station and portable controller +- [x] Parachutes for rack drops, containers on racks +- [x] Repelling and troop drops +- [x] `HideEntity`, search lights, splash and exhaust particles, helicopter blade folding +- [x] Marking blocks with targeting pods, mortar distance +- [x] The drafting table and its recipe browser (pack vehicles included) +- [x] Spotting with the rangefinder +- [x] Multiplayer team manager, and team-only markers for spotted targets ## Phase 11: workshop, model, parts and tuning (`1.1.0-beta.7`) diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md new file mode 100644 index 0000000..9934ed5 --- /dev/null +++ b/docs/SCREENSHOTS.md @@ -0,0 +1,118 @@ +# Screenshots + +Every phase of MCHeli Rework in development builds, newest first. The [README](../README.md) +shows the latest phase only. + +## Phase 10 (`1.1.0-beta.6`) + +The rest of the equipment: smoke grenades, the GLTD target designator, the rangefinder, cargo +containers and the tow chain. + +| | | +|---|---| +| ![A green M18 smoke cloud rising from a hilltop by a lake](images/phase-10/m18-green-smoke.png) | ![The view through the GLTD sight over a desert village, a red cross on the target and the readout "A-10 on call"](images/phase-10/gltd-sight.png) | +| An M18 smoke grenade marks a spot in green | Through the GLTD's sight, with the A-10 on call | +| ![Two A-10s pulling away over a desert hill after strafing it, the readout counting down "A-10 rearming"](images/phase-10/gltd-a-10-strike.png) | ![A player sitting behind the GLTD tripod, its red laser running out over the sand](images/phase-10/gltd-tripod.png) | +| The A-10 makes its gun runs over the marked spot | The GLTD from outside: the head follows the aim, with a faint laser | +| ![The rangefinder scope on an AH-64 at 163 metres, the spot modes listed at the side](images/phase-10/rangefinder-scope.png) | ![A cargo container coming down over a desert under two parachutes](images/phase-10/container-parachute-drop.png) | +| The rangefinder reads the range to an AH-64 | A container dropped from a rack comes down under a parachute | +| ![A cargo container chained to the side of an M1A2 tank](images/phase-10/chain-tow-m1a2.png) | ![A CH-47 flying over canyons with a container hanging below it on the tow chain](images/phase-10/ch47-sling-load.png) | +| An M1A2 with a container on the tow chain | A CH-47 carries a container as a sling load | + +## Phase 9 (`1.1.0-beta.5`) + +Thermal view, the vehicle screen, ejection seats with parachutes from the vehicle, and cargo. + +| | | +|---|---| +| ![The AH-64 cockpit in thermal view: the desert in white hot greys with outlines, the green HUD reading THERMAL VISION](images/phase-9/ah-64-thermal-view.png) | ![The cargo chest of a B1 Centauro, two rows of slots above the player inventory](images/phase-9/centauro-cargo-chest.png) | +| Thermal view from the AH-64's cockpit | The Centauro's cargo chest, opened from outside | +| ![The AH-64's vehicle screen: fuel slots on the left, the M230 chain gun at 15 of 1200 rounds with its reload cost of iron and gunpowder](images/phase-9/vehicle-screen-empty-reserve.png) | ![The same screen after reloading, the chain gun at 1200 of 1200 in green and the Reload button greyed out](images/phase-9/vehicle-screen-reloaded.png) | +| The vehicle screen with the chain gun's reserve empty | Reloaded from the player's own iron and gunpowder | +| ![A player in an ejection seat climbing away above a desert lake after leaving an F-35B](images/phase-9/ejection-seat.png) | ![Looking down the parachute's rigging lines at the player in the seat, the F-35B and an AH-64 below](images/phase-9/ejection-parachute-open.png) | +| Ejecting from an F-35B, with a parachute from its slot | The canopy opens once the seat falls | + +## Phase 8 (`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](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](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](images/phase-8/sa-2-lock-on-plane.png) | ![The SA-2 bursting on the plane in a cloud of smoke and debris](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](images/phase-8/plane-crash-spin.png) | ![The wreck exploding on the ground in a desert village, debris arcing out](images/phase-8/plane-crash-site.png) | +| The plane rolls into a nose-down spiral | It goes up where it comes down | + +## Phase 6 (`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. + +| | | +|---|---| +| ![An SA-2 closing on a helicopter in the night sky, its motor glowing](images/phase-6/sa-2-night-intercept.png) | ![The shot down helicopter exploding on a hilltop at night in a bright flash](images/phase-6/helicopter-crash-night.png) | +| An SA-2 intercept at night | The helicopter goes up in a flash of light | +| ![Glowing embers and burning debris arcing over the burning wreck at night](images/phase-6/crash-embers-night.png) | ![A torpedo running towards a patrol boat at dusk](images/phase-6/torpedo-run-dusk.png) | +| Burning debris arcs over the wreck | A torpedo run at dusk | +| ![The patrol boat cooking off in a fireball against the sunset](images/phase-6/patrol-boat-cook-off-dusk.png) | ![Glowing debris raining down around the sinking patrol boat](images/phase-6/burning-debris-dusk.png) | +| The patrol boat cooks off | Glowing debris rains down as it sinks | + +## Phase 5 (`1.1.0-beta.1`) + +A patrol boat hit by a torpedo cooks off and sinks. + +| | | +|---|---| +| ![A patrol boat exploding on a river after a torpedo hit, burning debris arcing over the shore](images/phase-5/patrol-boat-cook-off.png) | ![The wreck of the patrol boat resting on the riverbed, listing and still smoking](images/phase-5/patrol-boat-wreck.png) | +| Torpedo hit: fuel and ammunition cook off in three blasts | The wreck settles on the bottom, still smoking | + +## Phase 4 + +The original's scripted HUDs, pilot gunner mode and lock-on. + +| | | +|---|---| +| ![A helicopter cockpit in flight with the helicopter HUD and radar](images/phase-4/helicopter-hud.png) | ![The S-75 launcher's sight with the SA-2 selected](images/phase-4/s-75-launcher.png) | +| Helicopter HUD: heading, pitch ladder, weapon, radar | The S-75 launcher with its SA-2 Guideline | +| ![The AC-130 gunner view with another plane in the sight](images/phase-4/ac-130-gunner-mode.png) | ![The AC-130 gunner HUD over the jungle](images/phase-4/ac-130-gunner-hud.png) | +| AC-130 gunner mode: the autopilot circles while the pilot aims the guns | The gunner HUD with the Bofors 40 mm selected | +| ![An SA-2 exploding on a helicopter right above the launcher](images/phase-4/sa-2-strike.png) | ![The smoke and burning debris of an SA-2 hit](images/phase-4/sa-2-burst.png) | +| An SA-2 strikes a helicopter over the launcher | The burst of a hit in mid-air | + +## Phase 3 + +Weapons, damage, crashes and the explosion effect. + +| | | +|---|---| +| ![A T-90 burning after a hit from another T-90](images/phase-3/tank-duel.png) | ![A shot down helicopter exploding as it hits the ground](images/phase-3/helicopter-crash.png) | +| Tank duel: the loser burns | A shot down helicopter explodes on impact | +| ![An M61 Vulcan bringing down a helicopter](images/phase-3/vulcan-shoot-down.png) | ![A damaged SH-3 Sea King smoking on the water](images/phase-3/damaged-sea-king.png) | +| An M61 Vulcan bringing down a helicopter | A damaged Sea King smokes, afloat on its floats | +| ![The view from a T-90 turret with the weapon readout](images/phase-3/tank-gunner.png) | ![Water seen through a plane's canopy](images/phase-3/canopy-over-water.png) | +| From the T-90 turret, reloading the main gun | Glass canopies show the water behind them | + +## Phase 2 + +Tanks, cars, ground launchers and planes with their moving parts. + +| | | +|---|---| +| ![A tracked tank and an eight-wheeled armoured car](images/phase-2/tank-and-armoured-car.png) | ![Tanks, an armoured car and an S-75 missile launcher](images/phase-2/ground-forces.png) | +| A tracked tank and an eight-wheeled armoured car | Ground forces next to an S-75 launcher | +| ![Tornado with its landing gear down](images/phase-2/tornado.png) | | +| Tornado on the ground, gear down and stores on its pylons | | + +## Phase 1 + +Running on Minecraft 26.3 with Forge 66.1.0. + +| | | +|---|---| +| ![Cockpit view while flying, banked to the right](images/phase-1/cockpit-in-flight.png) | ![SH-3 Sea King hovering](images/phase-1/sh-3-hovering.png) | +| Flying from the cockpit, with camera roll | SH-3 Sea King in the air | +| ![SH-3 Sea King climbing](images/phase-1/sh-3-climbing.png) | ![The MCHeli Helicopters creative tab](images/phase-1/creative-tab.png) | +| Main and tail rotors spinning | All 33 helicopters in the creative tab | + diff --git a/docs/TESTING.md b/docs/TESTING.md index 10b7490..8320023 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -524,6 +524,113 @@ Forge 26.3 only. Start from a world made by beta.4 with a few vehicles in it, in drop. - [ ] Park next to an `ammo_box`: weapons with a low reserve fill up a tenth at a time. +## Phase 10 checklist (`1.1.0-beta.6`) + +Forge 26.3 only. Start from a world made by beta.5, in survival. + +### Smoke grenades + +- [ ] The equipment tab lists the four M18 grenades with their own icons and names; each recipe + (dye next to an iron ingot) makes one. +- [ ] A short draw drops the grenade a few blocks away, a full draw throws it far. It bounces off + walls and the floor and settles. +- [ ] After two seconds it pours out smoke in its colour, which pales as it rises, for about 30 + seconds, then the grenade disappears. +- [ ] A dispenser with a grenade in it throws it out when powered. + +### GLTD + +- [ ] Placing the GLTD on a block sets it up facing the way you look; right-click sits behind it. +- [ ] First person looks out of the sight; the view stops 70 degrees either side and up or down. + Z steps the zoom up to x8 and back, K steps night vision, thermal view and back. +- [ ] Right-click on terrain: the readout counts down, and three A-10 passes strafe the spot. + Clicking again before the countdown ends does nothing. +- [ ] Another player sees the head turn with your aim and a faint red laser. +- [ ] Shift leaves it; punching it drops the item (creative: it just goes). + +### Rangefinder + +- [ ] Holding use raises the scope after a moment; Z and X zoom in and out while held, the range + reads in metres, and `----` when nothing is in range. +- [ ] C cycles the modes (in a single player world it skips players and vehicles). +- [ ] Left-click on mobs in mobs mode: red markers appear over them, the charge drops by 10%, and + the "pi" sound plays. Ten spots empty it; then only the "no" sound. Crafting it with redstone fills it. +- [ ] On a server, a spotted vehicle is marked for 15 seconds; a teammate is never marked. Mark + point sends the coordinates to you and your team in chat. + +### Stinger and Javelin + +- [ ] Both launchers and both missiles are in the equipment tab; each missile recipe makes two. +- [ ] Stinger raised at a flying helicopter: the lock tone quickens, LOCKED shows with the range, + left-click fires, and the missile chases the helicopter. A parked one does not lock. +- [ ] Javelin raised at a tank: Z switches 4.2x and 9.2x, X switches direct and top attack. Top + attack under 40 blocks shows LOCKED in red and refuses to fire; further away the missile + climbs and dives onto the tank. +- [ ] After firing the bar shows empty. Lower it with a missile in the inventory: "Reloading", the + reload sound, and after two seconds it is loaded and one missile is gone (none in creative). +- [ ] Left-click without a lock plays the "no" sound and fires nothing. + +### Container and chain + +- [ ] Placing a container sets it on the block; right-click opens a six row chest. Items survive a + reload. It floats in water. Punching it a few times breaks it and drops its contents. +- [ ] Park a `ch47` by a container and press J: it loads. U at height over open ground drops it + under a parachute; on the ground it just sets it down. +- [ ] Chain: right-click a container (or a car), then a truck within 16 blocks. Driving the truck + drags the other along at the chain's length. Right-click either end again: the chain comes + off. Save and reload while towing: the chain is still there. +- [ ] Chain the other way round (truck first, container second): the truck still does the pulling. + Chain a container to a `ch47` and lift off: the container hangs below it. Two containers + cannot be chained ("One end of the chain must be a vehicle"). + +### UAV station + +- [ ] Place a UAV station and right-click it: you sit down and its screen opens. The buttons move + the launch point; put an `mq-9` in the slot and it appears there and you fly it at once, + from its camera, with its HUD showing the range and link bars. +- [ ] Shift: you stand beside the station again and the drone stays where it was. Continue takes + it back. Fly it past 123 blocks: "link is lost", the drone goes down and you are back. +- [ ] While flying, another player sees no body in the drone, and hitting the drone does not hurt + you. Breaking the station while flying puts you back beside where it stood. +- [ ] The portable controller opens with Shift and right-click, refuses the `mq-9` ("Small UAVs + only") and launches the `skylark`. +- [ ] The E-767 can be crafted with a UAV station. + +### Troops, search lights, blade folding and particles + +- [ ] Stand two villagers next to a `ch47` and climb in: they take seats. Hover still and level, + press Y: ropes drop and the villagers slide down one by one. Y again pulls the ropes up. A + second player in the back presses Y to rappel. +- [ ] An `an2` or `mv-22` flown high: Y opens the hatch and the villagers jump under + parachutes one after another. +- [ ] The `mi-24ps` or `bnr32` at night: K in the pilot or first crew seat switches the beams; + a turning light follows the gunner's aim. +- [ ] An `sh-60` (folding blades), landed with the engine stopped: Z folds the blades onto the + tail, the throttle does nothing, A and D turn it slowly; Z again unfolds them. +- [ ] Hovering low raises dust (over water, spray). A Harrier in the hover blows smoke down; a jet + rolling fast kicks up the ground; a boat throws spray. +- [ ] The `m1129` mortar shows the landing distance on the HUD. A block targeting pod marks the + point under the view for the team. + +### Drafting table + +- [ ] Craft and place the drafting table: it shows the original model, facing you. Shift and + right-click switches its lamp on and off. +- [ ] Right-click: the five list buttons, << and >> step through the recipes, the grid shows the + ingredients cycling through their options, equipment shows its illustrated pages, vehicles + their numbers. Create is greyed out without the ingredients; with them it takes them and + fills the output slot. Closing the screen hands back what is left in it. + +### Team manager and markers + +- [ ] As an operator press M: the team manager opens; as a non-operator a message says it is for + operators only. Make two teams with colours; they show on the right and in `/team list`. +- [ ] Shuffle puts every player in a team; Switch PvP says on or off; Everyone to spawn moves + everybody; Destroy all vehicles removes every vehicle and reports how many. +- [ ] Rangefinder spot with two teams on a server: the spotter and their teammates see a red + triangle and distance over the targets, a diamond at the screen edge when looking away; the + other team sees nothing. Mark point shows a yellow diamond with coordinates for a minute. + ## 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-10/ch47-sling-load.png b/docs/images/phase-10/ch47-sling-load.png new file mode 100644 index 0000000..0270a7c Binary files /dev/null and b/docs/images/phase-10/ch47-sling-load.png differ diff --git a/docs/images/phase-10/chain-tow-m1a2.png b/docs/images/phase-10/chain-tow-m1a2.png new file mode 100644 index 0000000..248b3cd Binary files /dev/null and b/docs/images/phase-10/chain-tow-m1a2.png differ diff --git a/docs/images/phase-10/container-parachute-drop.png b/docs/images/phase-10/container-parachute-drop.png new file mode 100644 index 0000000..7c7f853 Binary files /dev/null and b/docs/images/phase-10/container-parachute-drop.png differ diff --git a/docs/images/phase-10/gltd-a-10-strike.png b/docs/images/phase-10/gltd-a-10-strike.png new file mode 100644 index 0000000..1b85eaa Binary files /dev/null and b/docs/images/phase-10/gltd-a-10-strike.png differ diff --git a/docs/images/phase-10/gltd-sight.png b/docs/images/phase-10/gltd-sight.png new file mode 100644 index 0000000..05f1157 Binary files /dev/null and b/docs/images/phase-10/gltd-sight.png differ diff --git a/docs/images/phase-10/gltd-tripod.png b/docs/images/phase-10/gltd-tripod.png new file mode 100644 index 0000000..30da8dc Binary files /dev/null and b/docs/images/phase-10/gltd-tripod.png differ diff --git a/docs/images/phase-10/m18-green-smoke.png b/docs/images/phase-10/m18-green-smoke.png new file mode 100644 index 0000000..d97b349 Binary files /dev/null and b/docs/images/phase-10/m18-green-smoke.png differ diff --git a/docs/images/phase-10/rangefinder-scope.png b/docs/images/phase-10/rangefinder-scope.png new file mode 100644 index 0000000..6a8ce31 Binary files /dev/null and b/docs/images/phase-10/rangefinder-scope.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 aa46450..20e3189 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 @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.client.CameraAngles; import com.jnhprojects.mcheli.client.ClientHooks; import com.jnhprojects.mcheli.client.particle.GlowParticle; +import com.jnhprojects.mcheli.client.particle.SmokeParticle; import com.jnhprojects.mcheli.registry.ModParticles; import net.minecraft.client.Camera; import net.minecraftforge.client.event.AddGuiOverlayLayersEvent; @@ -29,7 +30,11 @@ static void init(BusGroup modBus) { ClientHooks.init(); FMLClientSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(ClientHooks::registerMenuScreens)); EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerEntityRenderers(event::registerEntityRenderer)); - RegisterParticleProvidersEvent.BUS.addListener(event -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); + EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> ClientHooks.registerBlockEntityRenderers(event::registerBlockEntityRenderer)); + RegisterParticleProvidersEvent.BUS.addListener(event -> { + event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new); + event.registerSpriteSet(ModParticles.SMOKE.get(), SmokeParticle.Provider::new); + }); RegisterKeyMappingsEvent.BUS.addListener(event -> ClientHooks.keyMappings().forEach(event::register)); RegisterClientReloadListenersEvent.BUS.addListener(event -> ClientHooks.reloadListeners().values().forEach(event::registerReloadListener)); AddGuiOverlayLayersEvent.BUS.addListener(event -> event.getLayeredDraw() diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java index 42082fa..4028f53 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/forge/McheliForge.java @@ -16,6 +16,7 @@ import net.minecraftforge.event.server.ServerStoppedEvent; import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.RegisterEvent; @@ -37,6 +38,7 @@ public McheliForge(FMLJavaModLoadingContext context) { helper -> helper.register(VehicleIngredient.ID, ForgeVehicleIngredient.SERIALIZER)); }); ForgeNetwork.register(); + FMLCommonSetupEvent.getBus(modBus).addListener(event -> event.enqueueWork(CommonHooks::commonSetup)); AddPackFindersEvent.BUS.addListener(event -> event.addRepositorySource(CommonHooks.packSource())); ServerAboutToStartEvent.BUS.addListener(event -> CommonHooks.serverAboutToStart(event.getServer())); diff --git a/gradle.properties b/gradle.properties index f73d9ed..9fdae84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ net.minecraftforge.gradle.merge-source-sets=true mod_id=mcheli mod_name=MCHeli Rework mod_group=com.jnhprojects.mcheli -mod_version=1.1.0-beta.5 +mod_version=1.1.0-beta.6 mod_authors=JayMar921 (rework), EMB4 (original author), Fureniku (CurseForge maintainer) mod_license=MCHeli Rework Licence 1.0 (see LICENSE) diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java index d7b91d8..2cf2f6a 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/CommonHooks.java @@ -1,12 +1,16 @@ package com.jnhprojects.mcheli; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.item.ThrowableItem; import com.jnhprojects.mcheli.network.PackSync; +import com.jnhprojects.mcheli.registry.ModItems; import com.mojang.brigadier.CommandDispatcher; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.packs.repository.RepositorySource; import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.block.DispenserBlock; import net.minecraft.world.level.storage.LevelResource; import java.util.List; @@ -19,6 +23,11 @@ public final class CommonHooks { private CommonHooks() { } + /** Once the registries are frozen, on the main thread: what vanilla keeps in its own tables. */ + public static void commonSetup() { + DispenserBlock.registerBehavior(ModItems.THROWABLE.get(), new ThrowableItem.DispenseBehavior()); + } + /** * Adds the imported original content to the resource packs (client) and data packs (server). * Without the original zip there is nothing to add. @@ -52,12 +61,16 @@ public static void registerCommands(CommandDispatcher dispat } /** - * The vehicle recipes live in {@code data/mcheli/recipe} of the imported content pack. The - * original listed them on its drafting table; until that is ported, every MCHeli recipe goes - * into a player's recipe book when they join, so they can be found. + * The vehicle recipes live in {@code data/mcheli/recipe} of the imported content pack. Every + * MCHeli recipe goes into a player's recipe book when they join: the drafting table lists them + * from there, and they can be found in the crafting table's book too. */ public static void playerLoggedIn(ServerPlayer player) { PackSync.onLogin(player); + // A remote pilot whose drone did not come back with them is shown and made mortal again. + if (!(player.getVehicle() instanceof AircraftEntity uav && uav.isRemotePilot(player))) { + AircraftEntity.clearRemotePilot(player); + } List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() .filter(holder -> holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID)) .toList(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java index 002caf0..1a89065 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -2,6 +2,8 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import com.jnhprojects.mcheli.registry.ModBlocks; import com.jnhprojects.mcheli.registry.ModCreativeTabs; import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; @@ -38,6 +40,8 @@ public static void init() { legacyContent = LegacyContent.prepare(platform.gameDir()); packs = new ContentPacks(platform.gameDir(), legacyContent); content = packs.load(false); + ModBlocks.init(); + ModBlockEntities.init(); ModDataComponents.init(); ModEntities.init(); ModParticles.init(); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java new file mode 100644 index 0000000..8529b1f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlock.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jspecify.annotations.Nullable; + +/** + * The drafting table, {@code MCH_DraftingTableBlock}: right-click opens the recipe browser, and + * Shift and right-click switches its lamp. It turns in eighths of a circle to face whoever placed + * it, and its block entity draws the original model. + */ +public class DraftingTableBlock extends BaseEntityBlock { + public static final IntegerProperty ROTATION = IntegerProperty.create("rotation", 0, 7); + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + private static final VoxelShape SHAPE = Block.column(16.0, 0.0, 15.0); + private static final Component TITLE = Component.translatable("block.mcheli.drafting_table"); + + public DraftingTableBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(ROTATION, 0).setValue(LIT, false)); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(ROTATION, LIT); + } + + /** {@code onBlockPlacedBy}: the nearest eighth of the placer's heading. */ + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + float yaw = context.getPlayer() != null ? context.getPlayer().getYRot() : 0.0F; + int rotation = (int) ((Mth.positiveModulo(yaw, 360.0F) + 22.5F) / 45.0F) % 8; + return defaultBlockState().setValue(ROTATION, rotation); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (player.isSecondaryUseActive()) { + level.setBlock(pos, state.cycle(LIT), Block.UPDATE_ALL); + level.playSound(null, pos, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.BLOCKS, 0.3F, 0.5F); + } else { + player.openMenu(state.getMenuProvider(level, pos)); + } + return InteractionResult.SUCCESS; + } + + @Override + protected @Nullable MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { + return new SimpleMenuProvider((id, inventory, player) -> new DraftingTableMenu(id, inventory, ContainerLevelAccess.create(level, pos)), TITLE); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + /** The block entity renderer draws the table. */ + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new DraftingTableBlockEntity(pos, state); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java new file mode 100644 index 0000000..56238f3 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/block/DraftingTableBlockEntity.java @@ -0,0 +1,13 @@ +package com.jnhprojects.mcheli.block; + +import com.jnhprojects.mcheli.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** {@code MCH_DraftingTableTileEntity}: holds nothing; it is there so the table's model can be drawn. */ +public class DraftingTableBlockEntity extends BlockEntity { + public DraftingTableBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.DRAFTING_TABLE.get(), pos, state); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientHooks.java index ae92df7..0e9f82a 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 @@ -6,18 +6,30 @@ import com.jnhprojects.mcheli.client.hud.HudScripts; import com.jnhprojects.mcheli.client.render.A10Renderer; import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import com.jnhprojects.mcheli.client.render.ChainRenderer; +import com.jnhprojects.mcheli.client.render.DraftingTableRenderer; +import com.jnhprojects.mcheli.client.render.EquipmentRenderer; import com.jnhprojects.mcheli.client.render.FlareRenderer; +import com.jnhprojects.mcheli.client.render.GltdRenderer; import com.jnhprojects.mcheli.client.render.Glow; import com.jnhprojects.mcheli.client.render.MeshCache; import com.jnhprojects.mcheli.client.render.ParachuteRenderer; import com.jnhprojects.mcheli.client.render.ProjectileRenderer; import com.jnhprojects.mcheli.client.render.RiderPose; +import com.jnhprojects.mcheli.client.render.ThrowableRenderer; +import com.jnhprojects.mcheli.client.render.UavStationRenderer; +import com.jnhprojects.mcheli.client.screen.DraftingTableScreen; +import com.jnhprojects.mcheli.client.screen.TeamManagerScreen; +import com.jnhprojects.mcheli.client.screen.UavStationScreen; import com.jnhprojects.mcheli.client.screen.VehicleScreen; import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; import com.jnhprojects.mcheli.entity.ClientAccess; import com.jnhprojects.mcheli.platform.Platform; +import com.jnhprojects.mcheli.registry.ModBlockEntities; import com.jnhprojects.mcheli.registry.ModEntities; import com.jnhprojects.mcheli.registry.ModMenus; +import com.jnhprojects.mcheli.team.Markers; +import com.jnhprojects.mcheli.team.TeamManager; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Camera; import net.minecraft.client.DeltaTracker; @@ -26,13 +38,19 @@ import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.screens.MenuScreens; import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.NoopRenderer; import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.PreparableReloadListener; import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; import java.util.List; import java.util.Map; @@ -80,11 +98,29 @@ public static void registerEntityRenderers(EntityRenderers renderers) { renderers.register(ModEntities.FLARE.get(), FlareRenderer::new); renderers.register(ModEntities.A10.get(), A10Renderer::new); renderers.register(ModEntities.PARACHUTE.get(), ParachuteRenderer::new); + renderers.register(ModEntities.THROWABLE.get(), ThrowableRenderer::new); + renderers.register(ModEntities.GLTD.get(), GltdRenderer::new); + renderers.register(ModEntities.CHAIN.get(), ChainRenderer::new); + renderers.register(ModEntities.CONTAINER.get(), context -> new EquipmentRenderer<>(context, "container", 0.2F, 0xFFBFBFBF, 1.0F)); + renderers.register(ModEntities.UAV_STATION.get(), UavStationRenderer::new); + renderers.register(ModEntities.RAPPEL.get(), NoopRenderer::new); + } + + /** How a loader registers a block entity renderer. */ + public interface BlockEntityRenderers { + void register(BlockEntityType type, + BlockEntityRendererProvider provider); + } + + public static void registerBlockEntityRenderers(BlockEntityRenderers renderers) { + renderers.register(ModBlockEntities.DRAFTING_TABLE.get(), DraftingTableRenderer::new); } /** On the main thread during client setup, once our menu types are registered. */ public static void registerMenuScreens() { MenuScreens.register(ModMenus.VEHICLE.get(), VehicleScreen::new); + MenuScreens.register(ModMenus.UAV_STATION.get(), UavStationScreen::new); + MenuScreens.register(ModMenus.DRAFTING_TABLE.get(), DraftingTableScreen::new); } public static List keyMappings() { @@ -104,15 +140,36 @@ public static void clientTickEnd() { Glow.assignShaderPrograms(); } CrewController.onClientTick(); + GltdController.onClientTick(); + RangefinderController.onClientTick(); + LauncherController.onClientTick(); VehicleCamera.tick(); WeaponController.onClientTick(); HudRenderer.tick(); EngineSoundPlayer.tick(); + TargetMarkers.tick(); + while (KeyBindings.TEAM_MANAGER.consumeClick()) { + openTeamManager(); + } + } + + /** Operators get the team manager; anyone else is told it is theirs to open. */ + private static void openTeamManager() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.gui.screen() != null) { + return; + } + if (TeamManager.mayManage(mc.player)) { + mc.gui.setScreen(new TeamManagerScreen()); + } else { + mc.player.sendOverlayMessage(Component.translatable("message.mcheli.team.operators_only")); + } } /** Tilts the view with the hull and moves the eye into the cockpit or onto a TV missile. */ public static void computeCameraAngles(CameraAngles angles) { CrewController.onCameraAngles(angles); + GltdController.onCameraAngles(angles); } /** Sets the camera's rotation, roll included, the way Forge's {@code Camera#setRotation} does. */ @@ -190,12 +247,12 @@ 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); + return LauncherController.fov(RangefinderController.fov(VehicleCamera.fov(fov))); } /** Whether to skip drawing the first person hand this frame. */ public static boolean hidesHand() { - return CrewController.hidesHand(); + return CrewController.hidesHand() || GltdController.hidesHand() || RangefinderController.isScoped() || LauncherController.isRaised(); } /** What happens to a press of the use key. */ @@ -209,7 +266,7 @@ public enum UseKey { } public static UseKey onUseKey(InteractionHand hand) { - if (WeaponController.onUseItem()) { + if (WeaponController.onUseItem() || GltdController.onUseItem()) { return UseKey.CONSUME; } return VehiclePicker.onUseItem(hand) ? UseKey.CONSUME_AND_SWING : UseKey.PASS; @@ -226,6 +283,10 @@ public static void beforeRenderLiving(LivingEntityRenderState state, PoseStack p public static void drawHud(GuiGraphicsExtractor graphics, DeltaTracker delta) { WeaponHud.draw(graphics, delta); + GltdController.drawHud(graphics, delta); + RangefinderController.drawHud(graphics, delta); + LauncherController.drawHud(graphics, delta); + TargetMarkers.draw(graphics, delta); } /** Without the original zip there are no vehicles, so say so where the player will see it. */ @@ -235,5 +296,6 @@ public static void loggedIn(LocalPlayer player) { public static void loggedOut() { ClientPacks.INSTANCE.loggedOut(); + Markers.clear(); } } 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 1772884..32af677 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 @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.client; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; import com.jnhprojects.mcheli.client.hud.HudRenderer; import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; @@ -21,6 +22,7 @@ import net.minecraft.client.Camera; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundSource; import net.minecraft.util.Mth; import net.minecraft.world.entity.player.Input; import net.minecraft.world.phys.Vec3; @@ -99,7 +101,10 @@ static void onClientTick() { tracking = false; viewVehicle = null; viewSeat = -1; - VehicleCamera.reset(); + // The GLTD's sight shares the zoom and camera mode; only leaving it resets them. + if (GltdController.gltdOf(player) == null) { + VehicleCamera.reset(); + } return; } int seat = vehicle.seatOf(player); @@ -157,6 +162,9 @@ static void onClientTick() { while (KeyBindings.SWITCH_SEAT.consumeClick()) { send(vehicle, CrewActionPacket.Action.SWITCH_SEAT); } + while (KeyBindings.DROP_CREW.consumeClick()) { + send(vehicle, pilot ? CrewActionPacket.Action.DROP_CREW : CrewActionPacket.Action.JUMP_OUT); + } while (KeyBindings.SWITCH_CAMERA.consumeClick()) { switchCamera(vehicle, seat); } @@ -173,7 +181,13 @@ static void onClientTick() { } } while (KeyBindings.CAMERA_MODE.consumeClick()) { - VehicleCamera.onCameraModeKey(player, vehicle); + // As in the original, the key works the search light in vehicles that have one. + if (vehicle.canSwitchSearchLight(player)) { + send(vehicle, CrewActionPacket.Action.SEARCH_LIGHT); + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 1.0F); + } else { + VehicleCamera.onCameraModeKey(player, vehicle); + } } while (KeyBindings.VEHICLE_SCREEN.consumeClick()) { if (!vehicle.isDestroyed()) { @@ -433,7 +447,7 @@ private static void moveCamera(Camera camera, AircraftEntity vehicle, AircraftDe return missile; } - private static void placeCamera(Camera camera, Vec3 at) { + static void placeCamera(Camera camera, Vec3 at) { MethodHandle setter = cameraSetter(); if (setter == null) { return; diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/GltdController.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/GltdController.java new file mode 100644 index 0000000..7d2f31d --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/GltdController.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.network.EquipmentActionPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * The local player at a GLTD, {@code MCH_ClientGLTDTickHandler} and {@code MCH_GuiGLTD}: the + * view moves to the sight, the zoom and camera mode keys work as in a vehicle's sight, the use + * key calls the A-10 on the spot under the cross, and a small readout says when it can be called + * again. + */ +final class GltdController { + /** The original zoomed smoothly up to ten; the vehicle sights double each press. */ + private static final int MAX_ZOOM = 8; + private static final int WHITE = 0xFFFFFFFF; + private static final int AMBER = 0xFFFFC040; + private static final int CROSS = 0xC0FF4040; + + /** Edge of the use key, so holding it calls one strike rather than one per tick. */ + private static boolean useWasDown; + + private GltdController() { + } + + static @Nullable GltdEntity gltdOf(@Nullable LocalPlayer player) { + return player != null && player.getVehicle() instanceof GltdEntity gltd ? gltd : null; + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + GltdEntity gltd = gltdOf(player); + if (gltd == null) { + useWasDown = false; + return; + } + while (KeyBindings.FOLD.consumeClick()) { + VehicleCamera.zoomStep(player, MAX_ZOOM); + } + while (KeyBindings.CAMERA_MODE.consumeClick()) { + VehicleCamera.nextMode(player, on -> send(gltd, + on ? EquipmentActionPacket.Action.NIGHT_VISION_ON : EquipmentActionPacket.Action.NIGHT_VISION_OFF)); + } + boolean useDown = mc.gui.screen() == null && mc.options.keyUse.isDown(); + if (useDown && !useWasDown) { + send(gltd, EquipmentActionPacket.Action.CALL_STRIKE); + } + useWasDown = useDown; + } + + /** The use key belongs to the sight while sitting at a GLTD. */ + static boolean onUseItem() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + /** First person looks out of the sight rather than from behind the tripod. */ + static void onCameraAngles(CameraAngles angles) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd != null && mc.options.getCameraType().isFirstPerson()) { + CrewController.placeCamera(angles.camera(), gltd.sightPosition(angles.partialTick())); + } + } + + static boolean hidesHand() { + return gltdOf(Minecraft.getInstance().player) != null; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + GltdEntity gltd = gltdOf(mc.player); + if (gltd == null || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int cx = g.guiWidth() / 2; + int cy = g.guiHeight() / 2; + g.fill(cx - 12, cy, cx - 3, cy + 1, CROSS); + g.fill(cx + 4, cy, cx + 13, cy + 1, CROSS); + g.fill(cx, cy - 12, cx + 1, cy - 3, CROSS); + g.fill(cx, cy + 4, cx + 1, cy + 13, CROSS); + + Font font = mc.font; + int x = cx + 20; + int y = cy + 20; + int wait = gltd.waitTicks(); + if (wait > 0) { + g.text(font, Component.translatable("hud.mcheli.gltd.wait", (wait + 19) / 20), x, y, AMBER); + } else { + g.text(font, Component.translatable("hud.mcheli.gltd.ready"), x, y, WHITE); + } + g.text(font, Component.translatable("hud.mcheli.zoom", Math.round(VehicleCamera.zoom())), x, y + 10, WHITE); + } + + private static void send(GltdEntity gltd, EquipmentActionPacket.Action action) { + ModNetwork.sendToServer(new EquipmentActionPacket(gltd.getId(), action)); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java index df4ab7e..111759a 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 @@ -37,9 +37,13 @@ public final class KeyBindings { public static final KeyMapping VEHICLE_SCREEN = new KeyMapping("key.mcheli.vehicle_screen", InputConstants.KEY_I, CATEGORY); /** Night vision, thermal view and back in vehicles with {@code EnableNightVision}. The original used C, now the flare key. */ public static final KeyMapping CAMERA_MODE = new KeyMapping("key.mcheli.camera_mode", InputConstants.KEY_K, CATEGORY); + /** The pilot lets the troops down; anyone else aboard slides down a rope or jumps. The original used Y for the pilot's part. */ + public static final KeyMapping DROP_CREW = new KeyMapping("key.mcheli.drop_crew", InputConstants.KEY_Y, CATEGORY); + /** Operators open the team manager, as the original's multiplayer manager key M did. */ + public static final KeyMapping TEAM_MANAGER = new KeyMapping("key.mcheli.team_manager", InputConstants.KEY_M, CATEGORY); public static final List ALL = List.of(FREE_LOOK, SWITCH_CAMERA, LANDING_GEAR, FOLD, VTOL, SWITCH_SEAT, RACK_LOAD, RACK_UNLOAD, - SWITCH_WEAPON, WEAPON_MODE, FLARE, VEHICLE_SCREEN, CAMERA_MODE); + SWITCH_WEAPON, WEAPON_MODE, FLARE, VEHICLE_SCREEN, CAMERA_MODE, DROP_CREW, TEAM_MANAGER); private KeyBindings() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java new file mode 100644 index 0000000..df6c23f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/LauncherController.java @@ -0,0 +1,238 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.network.LauncherPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.weapon.Guidance; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The Stinger and Javelin in the player's hands, {@code MCH_ClientLightWeaponTickHandler} and + * {@code MCH_GuiLightWeapon}. Raised, the launcher's sight covers the view and its guidance locks + * onto what the cross holds; the attack key fires once the lock is complete. Z steps the zoom, X + * the Javelin's direct and top attack modes. An empty launcher held down reloads itself from a + * missile in the inventory. + */ +final class LauncherController { + /** Ticks of raising before the sight and the lock start, as in the original. */ + private static final int RAISE_TICKS = 10; + private static final int RELOAD_TICKS = 40; + /** Top attack climbs first and needs this much room, measured flat. */ + private static final double TOP_ATTACK_MIN_DISTANCE = 40.0; + private static final int GREEN = 0xFF2AD848; + private static final int RED = 0xFFDF0808; + private static final int WHITE = 0xFFFFFFFF; + + private static @Nullable String weapon; + private static @Nullable Guidance guidance; + private static int zoomIndex; + private static int mode; + private static int reloadTicks; + private static int lockSoundTicks; + private static boolean attackWasDown; + + private LauncherController() { + } + + private static ItemStack launcher(@Nullable LocalPlayer player) { + if (player == null || !LauncherItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); + return stack.getItem() instanceof LauncherItem ? stack : ItemStack.EMPTY; + } + + /** Whether the player holds a launcher up to the eye. */ + static boolean isRaised() { + LocalPlayer player = Minecraft.getInstance().player; + ItemStack stack = launcher(player); + return !stack.isEmpty() && player.isUsingItem() && player.getUseItem() == stack && player.getTicksUsingItem() > RAISE_TICKS; + } + + private static @Nullable WeaponDefinition definition(ItemStack stack) { + return MCHeli.content().weapon(((LauncherItem) stack.getItem()).weapon()); + } + + static void onClientTick() { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = launcher(player); + WeaponDefinition def = stack.isEmpty() ? null : definition(stack); + if (def == null) { + weapon = null; + guidance = null; + reloadTicks = 0; + attackWasDown = false; + return; + } + String name = ((LauncherItem) stack.getItem()).weapon(); + if (!name.equals(weapon) || guidance == null) { + weapon = name; + boolean air = def.type != WeaponType.AT_MISSILE; + guidance = new Guidance(air, !air, def.ridableOnly, def.lockTime, e -> e != player && !e.isAlliedTo(player)); + zoomIndex = 0; + mode = 0; + } + boolean raised = isRaised(); + boolean attackDown = raised && mc.gui.screen() == null && mc.options.keyAttack.isDown(); + boolean attackPressed = attackDown && !attackWasDown; + attackWasDown = attackDown; + + if (LauncherItem.isLoaded(stack)) { + reloadTicks = 0; + if (raised) { + if (guidance.lock(player, player.getEyePosition())) { + lockSoundTicks = 0; + } + playLockSound(player); + } else { + guidance.clear(); + } + } else { + guidance.clear(); + reload(player, stack); + } + guidance.tick(); + + if (!raised) { + return; + } + while (KeyBindings.FOLD.consumeClick()) { + if (def.zoom.length > 1) { + zoomIndex = (zoomIndex + 1) % def.zoom.length; + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + while (KeyBindings.WEAPON_MODE.consumeClick()) { + if (def.modeNum > 1) { + mode = (mode + 1) % def.modeNum; + player.level().playLocalSound(player, ModSounds.get("pi"), SoundSource.PLAYERS, 0.5F, 0.9F); + } + } + if (attackPressed) { + Entity target = guidance.lastLocked(); + if (LauncherItem.isLoaded(stack) && guidance.isLocked() && target != null && canFire(player, target)) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.FIRE, target.getId(), mode)); + guidance.clear(); + } else { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 0.5F, 1.0F); + } + } + } + + /** Top attack needs room to climb and dive. */ + private static boolean canFire(LocalPlayer player, @Nullable Entity target) { + if (mode == 0 || target == null) { + return true; + } + double dx = target.getX() - player.getX(); + double dz = target.getZ() - player.getZ(); + return Math.sqrt(dx * dx + dz * dz) >= TOP_ATTACK_MIN_DISTANCE; + } + + /** The tone quickens as the lock builds. */ + private static void playLockSound(LocalPlayer player) { + if (guidance == null || guidance.lockCount() <= 0) { + lockSoundTicks = 0; + return; + } + if (lockSoundTicks > 0) { + lockSoundTicks--; + return; + } + lockSoundTicks = Math.max(2, (int) (7 * (1.0 - (double) guidance.lockCount() / guidance.lockCountMax()))); + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + + /** Held down and empty, with a missile to hand: two seconds to load it. */ + private static void reload(LocalPlayer player, ItemStack stack) { + LauncherItem item = (LauncherItem) stack.getItem(); + boolean hasMissile = player.getAbilities().instabuild || player.getInventory().findSlotMatchingItem(new ItemStack(item.missile())) >= 0; + if (!hasMissile || player.isUsingItem()) { + reloadTicks = 0; + return; + } + reloadTicks++; + if (reloadTicks == 10) { + player.level().playLocalSound(player, ModSounds.get("fim92_reload"), SoundSource.PLAYERS, 1.0F, 1.0F); + } + if (reloadTicks == RELOAD_TICKS) { + ModNetwork.sendToServer(new LauncherPacket(LauncherPacket.Action.RELOAD, 0, 0)); + } + } + + static float fov(float fov) { + if (!isRaised()) { + return fov; + } + WeaponDefinition def = definition(launcher(Minecraft.getInstance().player)); + if (def == null || def.zoom.length == 0) { + return fov; + } + float zoom = def.zoom[Math.min(zoomIndex, def.zoom.length - 1)]; + return zoom > 1.0F ? fov / zoom : fov; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (!mc.options.getCameraType().isFirstPerson()) { + return; + } + ItemStack stack = launcher(player); + if (stack.isEmpty()) { + return; + } + Font font = mc.font; + if (!isRaised()) { + if (!LauncherItem.isLoaded(stack) && reloadTicks > 0) { + g.text(font, Component.translatable("hud.mcheli.launcher.reloading"), g.guiWidth() / 2 - 30, g.guiHeight() / 2 + 20, WHITE); + } + return; + } + WeaponDefinition def = definition(stack); + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + String texture = def != null && def.type == WeaponType.AT_MISSILE ? "javelin" : "stinger"; + g.blit(RenderPipelines.GUI_TEXTURED, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/" + texture + ".png"), + (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + int x = w / 2 + 20; + int y = h / 2 + 20; + if (!LauncherItem.isLoaded(stack)) { + g.text(font, Component.translatable("hud.mcheli.launcher.empty"), x, y, RED); + } else if (guidance != null && guidance.isLocked()) { + Entity target = guidance.lastLocked(); + g.text(font, Component.translatable("hud.mcheli.locked"), x, y, canFire(player, target) ? GREEN : RED); + if (target != null) { + g.text(font, String.format("[%.0f m]", target.distanceTo(player)), x, y + 10, GREEN); + } + } else if (guidance != null && guidance.lockCount() > 0) { + g.text(font, Component.translatable("hud.mcheli.locking", guidance.lockCount() * 100 / Math.max(guidance.lockCountMax(), 1)), x, y, WHITE); + } + if (def != null && def.modeNum > 1) { + g.text(font, Component.translatable(mode == 0 ? "hud.mcheli.launcher.direct" : "hud.mcheli.launcher.top_attack"), x, y + 20, WHITE); + } + if (def != null && def.zoom.length > 0) { + g.text(font, Component.translatable("hud.mcheli.zoom", String.format("%.1f", def.zoom[Math.min(zoomIndex, def.zoom.length - 1)])), + x, y + 30, WHITE); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java new file mode 100644 index 0000000..80aafc9 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/RangefinderController.java @@ -0,0 +1,155 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.SpotPacket; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; + +/** + * Looking through the rangefinder, {@code MCH_ClientToolTickHandler} and {@code MCH_GuiRangeFinder}. + * Once it has been held up for a moment the scope covers the view and reads the range; the zoom + * key and the weapon mode key zoom in and out while held, the flare key picks what to spot, and + * the attack key spots. + */ +final class RangefinderController { + private static final Identifier SCOPE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/rangefinder.png"); + /** Ticks of holding it up before the scope shows, as in the original. */ + private static final int RAISE_TICKS = 8; + private static final int COOLDOWN = 60; + private static final float MIN_ZOOM = 1.2F; + private static final float MAX_ZOOM = 10.0F; + private static final double RANGE = 300.0; + private static final int GREEN = 0xFF10FF20; + private static final int RED = 0xFFFF1010; + private static final int WHITE = 0xFFFFFFFF; + private static final int GREY = 0xFF404040; + + private static float zoom = 2.0F; + private static SpotPacket.Mode mode = SpotPacket.Mode.PLAYERS_AND_VEHICLES; + private static int cooldown; + + private RangefinderController() { + } + + /** The rangefinder the player is looking through, or empty. */ + private static ItemStack scope(LocalPlayer player) { + if (player == null || !player.isUsingItem() || player.getTicksUsingItem() <= RAISE_TICKS || !RangefinderItem.canUse(player)) { + return ItemStack.EMPTY; + } + ItemStack stack = player.getUseItem(); + return stack.getItem() instanceof RangefinderItem ? stack : ItemStack.EMPTY; + } + + static boolean isScoped() { + return !scope(Minecraft.getInstance().player).isEmpty(); + } + + static void onClientTick() { + if (cooldown > 0) { + cooldown--; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty()) { + zoom = 2.0F; + return; + } + if (KeyBindings.FOLD.isDown() && zoom < MAX_ZOOM) { + zoom = Math.min(zoom + zoom / 10.0F, MAX_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 1.0F); + } + if (KeyBindings.WEAPON_MODE.isDown() && zoom > MIN_ZOOM) { + zoom = Math.max(zoom - zoom / 10.0F, MIN_ZOOM); + player.level().playLocalSound(player, ModSounds.get("zoom"), SoundSource.PLAYERS, 0.05F, 0.9F); + } + while (KeyBindings.FLARE.consumeClick()) { + mode = SpotPacket.Mode.values()[(mode.ordinal() + 1) % SpotPacket.Mode.values().length]; + // Alone in a world there is no one else to spot, as the original reasoned. + boolean alone = mc.hasSingleplayerServer() && !mc.getSingleplayerServer().isPublished(); + if (alone && mode == SpotPacket.Mode.PLAYERS_AND_VEHICLES) { + mode = SpotPacket.Mode.MOBS; + } + player.level().playLocalSound(player, ModSounds.get("lockon"), SoundSource.PLAYERS, 1.0F, 0.9F); + } + if (mc.options.keyAttack.isDown() && cooldown == 0) { + if (mode != SpotPacket.Mode.MARK_POINT && RangefinderItem.charges(stack) <= 0) { + player.level().playLocalSound(player, ModSounds.get("ng"), SoundSource.PLAYERS, 1.0F, 1.0F); + } else { + cooldown = COOLDOWN; + ModNetwork.sendToServer(new SpotPacket(mode)); + } + } + } + + static float fov(float fov) { + return isScoped() ? fov / zoom : fov; + } + + static void drawHud(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + ItemStack stack = scope(player); + if (stack.isEmpty() || !mc.options.getCameraType().isFirstPerson()) { + return; + } + int w = g.guiWidth(); + int h = g.guiHeight(); + int size = Math.max(w, h); + g.blit(RenderPipelines.GUI_TEXTURED, SCOPE, (w - size) / 2, (h - size) / 2, 0.0F, 0.0F, size, size, 256, 256, 256, 256); + + // The readouts sit on the scope texture's own labels: lay them out in its 512 wide space, + // centred, as the original did, and scale that with the scope. + float k = size / 512.0F; + g.pose().pushMatrix(); + g.pose().translate(w / 2.0F, h / 2.0F); + g.pose().scale(k, k); + drawReadouts(g, mc.font, player, stack); + g.pose().popMatrix(); + } + + /** In scope space: the centre of the view is at 0, 0. */ + private static void drawReadouts(GuiGraphicsExtractor g, Font font, LocalPlayer player, ItemStack stack) { + int charge = RangefinderItem.charges(stack) * 100 / RangefinderItem.CHARGES; + // Over BATT. + g.text(font, String.format("%3d%%", charge), -100, 55, charge > 0 ? GREEN : RED); + if (charge <= 0) { + g.text(font, Component.translatable("hud.mcheli.rangefinder.empty"), -40, 55, RED); + } else { + // Over RANGE METERS. + Vec3 eye = player.getEyePosition(); + HitResult hit = player.level().clip(new ClipContext(eye, eye.add(player.getLookAngle().scale(RANGE)), + ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player)); + String range = hit.getType() == HitResult.Type.MISS ? "----" : String.format("%4d", Math.round(hit.getLocation().distanceTo(eye))); + g.text(font, range, 40, 55, hit.getType() == HitResult.Type.MISS ? RED : GREEN); + } + // The spot cooldown bar, and the zoom above it. + g.fill(-100, 51, -70, 53, GREEN); + g.fill(-100, 51, -100 + cooldown / 2, 53, RED); + g.text(font, String.format("x%.1f", zoom), -100, 31, WHITE); + int y = 21; + for (SpotPacket.Mode m : SpotPacket.Mode.values()) { + if (m == mode) { + g.text(font, ">", 30, y, WHITE); + } + g.text(font, Component.translatable("hud.mcheli.rangefinder." + m.name().toLowerCase(Locale.ROOT)), 40, y, m == mode ? WHITE : GREY); + y += 10; + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java new file mode 100644 index 0000000..19a387a --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/TargetMarkers.java @@ -0,0 +1,87 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3fc; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * {@code MCH_GuiTargetMarker}: the team's spotted targets and marked point on screen. A target + * gets a triangle over its head with its distance; one off screen gets a diamond at the edge on + * its side. The marked point gets a diamond with its distance and coordinates. + */ +final class TargetMarkers { + private static final int TARGET = 0xFFFF4040; + private static final int POINT = 0xFFFFD000; + private static final int EDGE = 12; + + private TargetMarkers() { + } + + static void tick() { + Markers.tick(); + } + + static void draw(GuiGraphicsExtractor g, DeltaTracker delta) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + float partialTicks = delta.getGameTimeDeltaPartialTick(false); + for (Map.Entry spotted : Markers.spotted().entrySet()) { + Entity entity = mc.level.getEntity(spotted.getKey()); + if (entity == null || entity == mc.player || !entity.isAlive()) { + continue; + } + Vec3 top = entity.getPosition(partialTicks).add(0.0, entity.getBbHeight() + 0.5, 0.0); + mark(g, mc, top, "▼", TARGET, "%.0fm".formatted(mc.player.distanceTo(entity)), null); + } + BlockPos point = Markers.point(); + if (point != null) { + Vec3 at = Vec3.atCenterOf(point).add(0.0, 1.0, 0.0); + String coordinates = "%d %d %d".formatted(point.getX(), point.getY(), point.getZ()); + mark(g, mc, at, "◆", POINT, "%.0fm".formatted(Math.sqrt(mc.player.distanceToSqr(at))), coordinates); + } + } + + private static void mark(GuiGraphicsExtractor g, Minecraft mc, Vec3 at, String symbol, int color, String distance, @Nullable String extra) { + Camera camera = mc.gameRenderer.mainCamera(); + Vector3fc forward = camera.forwardVector(); + Vec3 offset = at.subtract(camera.position()); + boolean ahead = offset.x * forward.x() + offset.y * forward.y() + offset.z * forward.z() > 0.0; + Vec3 ndc = mc.gameRenderer.projectPointToScreen(at); + int w = g.guiWidth(); + int h = g.guiHeight(); + double x = (ndc.x + 1.0) / 2.0 * w; + double y = (1.0 - ndc.y) / 2.0 * h; + Font font = mc.font; + if (ahead && x >= 0 && x <= w && y >= 0 && y <= h) { + g.centeredText(font, symbol, (int) x, (int) y - 4, color); + g.centeredText(font, distance, (int) x, (int) y + 6, color); + if (extra != null) { + g.centeredText(font, extra, (int) x, (int) y + 16, color); + } + return; + } + // Behind or off to the side: point the way along the screen edge. + double dx = ahead ? x - w / 2.0 : w / 2.0 - x; + double dy = ahead ? y - h / 2.0 : h / 2.0 - y; + double scale = Math.max(Math.abs(dx) / (w / 2.0 - EDGE), Math.abs(dy) / (h / 2.0 - EDGE)); + if (scale <= 0.0) { + return; + } + int ex = Mth.clamp((int) (w / 2.0 + dx / scale), EDGE, w - EDGE); + int ey = Mth.clamp((int) (h / 2.0 + dy / scale), EDGE, h - EDGE); + g.centeredText(font, "◆", ex, ey - 4, color); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/VehicleCamera.java index b94a0f9..48a565f 100644 --- 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 @@ -13,6 +13,7 @@ import net.minecraft.sounds.SoundSource; import java.util.List; +import java.util.function.Consumer; /** * The local crew member's sight, {@code MCH_Camera}: zoom for vehicles with a {@code CameraZoom} @@ -75,8 +76,12 @@ static void tick() { */ static void onZoomKey(LocalPlayer player, AircraftEntity vehicle) { AircraftDefinition def = vehicle.definition(); - int max = def != null ? def.cameraZoom : 1; - if (max <= 1 || vehicle.isDestroyed()) { + zoomStep(player, def != null && !vehicle.isDestroyed() ? def.cameraZoom : 1); + } + + /** One press of the zoom key on a sight that zooms up to {@code max}. */ + static void zoomStep(LocalPlayer player, int max) { + if (max <= 1) { refuse(player); return; } @@ -95,10 +100,18 @@ static void onCameraModeKey(LocalPlayer player, AircraftEntity vehicle) { refuse(player); return; } + nextMode(player, on -> ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), + on ? CrewActionPacket.Action.NIGHT_VISION_ON : CrewActionPacket.Action.NIGHT_VISION_OFF))); + } + + /** + * Steps to the next camera mode on a sight that has night vision. {@code nightVision} asks + * the server to switch the effect on or off, only when that changes. + */ + static void nextMode(LocalPlayer player, Consumer nightVision) { Mode next = Mode.values()[(mode.ordinal() + 1) % Mode.values().length]; if (mode == Mode.NORMAL || next == Mode.NORMAL) { - ModNetwork.sendToServer(new CrewActionPacket(vehicle.getId(), - next == Mode.NORMAL ? CrewActionPacket.Action.NIGHT_VISION_OFF : CrewActionPacket.Action.NIGHT_VISION_ON)); + nightVision.accept(next != Mode.NORMAL); } mode = next; player.level().playLocalSound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.3F, 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 135b865..60ceea7 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 @@ -47,6 +47,8 @@ final class HudState { final List enemyBlips = new ArrayList<>(); private @Nullable AircraftEntity vehicle; + /** Where the selected mortar would land, or -1. */ + private float mortarDistance = -1.0F; private int radarRotate; private int radarRotateO; private int altitude; @@ -214,7 +216,9 @@ void update(AircraftEntity vehicle, AircraftDefinition def, LocalPlayer player, v.set(HudVariable.INVENTORY, 0.0); v.set(HudVariable.HOVERING, vehicle instanceof HelicopterEntity && vehicle.isHoveringMode()); v.set(HudVariable.IS_UAV, def.uav); - v.set(HudVariable.UAV_FS, 0.0); + double uavDistance = vehicle.stationDistance(); + // {@code getUAV_Fs}: signal strength, full at the station and gone 120 blocks out. + v.set(HudVariable.UAV_FS, uavDistance < 0.0 ? 0.0 : 1.0 - Math.min(uavDistance, 120.0) / 120.0); } /** @@ -282,16 +286,19 @@ private void updateWeapon(AircraftEntity vehicle, int seat) { vars.set(HudVariable.IS_HEAT_WPN, def.maxHeatCount > 0); vars.set(HudVariable.RELOADING, set.isInPreparation()); vars.set(HudVariable.DSP_MT_DIST, def.displayMortarDistance); + // The original worked the arc out every fifth tick; it is cheap enough to follow every update. + mortarDistance = def.displayMortarDistance ? (float) set.landingDistance(vehicle) : -1.0F; } else { vars.set(HudVariable.IS_HEAT_WPN, 0.0); vars.set(HudVariable.RELOADING, 0.0); vars.set(HudVariable.DSP_MT_DIST, 0.0); + mortarDistance = -1.0F; } vars.set(HudVariable.RELOAD_TIME, reloadTime); vars.set(HudVariable.WPN_HEAT, heat); vars.set(HudVariable.SIGHT_TYPE, sightType); vars.set(HudVariable.LOCK, lock); - vars.set(HudVariable.MT_DIST, -1.0); + vars.set(HudVariable.MT_DIST, mortarDistance); } boolean hasWeapon() { @@ -330,7 +337,7 @@ boolean hasMagazine() { case WPN_RM_AMMO -> weaponRestAmmo; case RELOAD_PER -> reloadPercent; case RELOAD_SEC -> reloadSeconds; - case MORTAR_DIST -> -1.0F; + case MORTAR_DIST -> mortarDistance; case MC_VER -> "26.3"; case MOD_VER -> Platform.get().modVersion(); case MOD_NAME -> "MCHeli Rework"; @@ -341,7 +348,7 @@ boolean hasMagazine() { case PLYR_PITCH -> -player.getXRot(); case TVM_POS_X, TVM_POS_Y, TVM_POS_Z, TVM_DIFF -> tvMissile(arg, vehicle); case CAM_ZOOM -> VehicleCamera.zoom(); - case UAV_DIST -> 0.0; + case UAV_DIST -> Math.max(vehicle.stationDistance(), 0.0); case THROTTLE -> vehicle.visibleThrottle() * 100.0; }; } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java new file mode 100644 index 0000000..ff6c3e7 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/SmokeParticle.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import net.minecraft.client.Camera; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleProvider; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.level.QuadParticleRenderState; +import net.minecraft.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; + +/** + * {@code mcheli:smoke}: a puff of coloured smoke, {@code MCH_EntityParticleSmoke}. It swells to its + * full size over the first part of its life, its colour drifts towards white, it rises slowly and + * fades out at the end. The sprites are vanilla's campfire smoke. + */ +public class SmokeParticle extends SingleQuadParticle { + private final float size; + + SmokeParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, SmokeParticleOptions options, + SpriteSet sprites, RandomSource random) { + super(level, x, y, z, sprites.get(random)); + setParticleSpeed(xd, yd, zd); + // The original drew a puff 0.1 x its size out from the middle, so size 10 is two blocks across. + this.size = options.size() * 0.1F * (0.8F + random.nextFloat() * 0.4F); + this.quadSize = size * 0.4F; + this.lifetime = options.lifetime() > 0 ? options.lifetime() : 60 + random.nextInt(40); + this.friction = 0.96F; + this.gravity = -0.002F; + this.hasPhysics = true; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.9F; + } + + @Override + public void tick() { + super.tick(); + // toWhite: pale towards the brightest channel while the colour is still strong. + float max = Math.max(rCol, Math.max(gCol, bCol)); + float min = Math.min(rCol, Math.min(gCol, bCol)); + if (max - min > 0.2F) { + rCol += (max - rCol) * 0.016F; + gCol += (max - gCol) * 0.016F; + bCol += (max - bCol) * 0.016F; + } + } + + @Override + public void extract(QuadParticleRenderState state, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + alpha = 0.9F * Math.min(1.0F, (1.0F - t) * 4.0F); + super.extract(state, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return size * (0.4F + 0.6F * Math.min(1.0F, t * 3.0F)); + } + + @Override + protected Layer getLayer() { + return Glow.SMOKE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public Particle createParticle(SmokeParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd, RandomSource random) { + return new SmokeParticle(level, x, y, z, xd, yd, zd, options, sprites, random); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java index 07775ee..09defd8 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -17,12 +17,21 @@ public class AircraftRenderState extends EntityRenderState { public float roll; /** Helicopter main rotor or plane propeller angle. */ public float rotorAngle; + /** Each helicopter blade's own angle, per rotor, so folding blades can leave the rotor's spin. */ + public float[][] bladeAngles = new float[0][]; public float throttle; // Part states, 0 to 90 degrees public float canopy; public float landingGear; public float hatch; + /** Rope length below the hooks while troops go down, 0 with the ropes in. */ + public float ropeLength; + /** The beams of the search lights, or false with them off. */ + public boolean searchLight; + /** Where the crew aim a turning search light, in world degrees. */ + public float searchLightYaw; + public float searchLightPitch; public float nozzle; public float wing; 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 a1aa623..3bbded4 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 @@ -3,6 +3,10 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.content.aircraft.SearchLight; +import com.jnhprojects.mcheli.content.aircraft.RepellingHook; import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; import com.jnhprojects.mcheli.weapon.VehicleWeapons; import com.jnhprojects.mcheli.weapon.WeaponSet; @@ -13,6 +17,7 @@ import com.jnhprojects.mcheli.entity.PlaneEntity; import com.jnhprojects.mcheli.model.Mesh; import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.math.Axis; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.components.debug.DebugScreenEntries; @@ -28,6 +33,7 @@ import net.minecraft.resources.Identifier; import net.minecraft.util.LightCoordsUtil; import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; @@ -41,6 +47,9 @@ public class AircraftRenderer extends EntityRenderer { private static final int HULL_COLOR = 0xFF00E0FF; private static final int GENERATED_COLOR = 0xFFFFC000; + /** Ropes take the colour of brown wool. */ + private static final Identifier ROPE = Identifier.withDefaultNamespace("textures/block/brown_wool.png"); + private static final float ROPE_WIDTH = 0.03F; public AircraftRenderer(EntityRendererProvider.Context context) { super(context); @@ -96,6 +105,16 @@ public void extractRenderState(T entity, AircraftRenderState state, float partia state.canopy = angle(entity.canopy(), partialTicks); state.landingGear = angle(entity.landingGear(), partialTicks); state.hatch = angle(entity.hatch(), partialTicks); + if (entity instanceof HelicopterEntity heli && def instanceof HelicopterDefinition heliDef) { + bladeAngles(heli, heliDef, state, partialTicks); + } + state.ropeLength = entity.isRepelling() ? entity.ropeLength() : 0.0F; + state.searchLight = entity.isSearchLightOn(); + if (state.searchLight) { + Entity aimer = entity.searchLightAimer(); + state.searchLightYaw = aimer != null ? aimer.getYRot(partialTicks) : state.yaw; + state.searchLightPitch = aimer != null ? aimer.getXRot(partialTicks) : state.pitch; + } if (entity instanceof PlaneEntity plane) { state.nozzle = angle(plane.nozzle(), partialTicks); state.wing = angle(plane.wing(), partialTicks); @@ -241,11 +260,82 @@ public void submit(AircraftRenderState state, PoseStack poseStack, SubmitNodeCol poseStack.rotateDegrees(Axis.ZP, state.roll); new PartRenderer(def, mesh, state, poseStack, collector, texture(def.folder(), state.texture), state.lightCoords, OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); + if (state.ropeLength < 0.0F) { + ropes(def, state, poseStack, collector); + } + if (state.searchLight) { + searchLights(def, state, poseStack, collector); + } poseStack.popPose(); } super.submit(state, poseStack, collector, camera); } + /** + * {@code renderLight}: each lamp's cone. A turning light follows the crew's aim; a fixed one + * points along the vehicle, and a steering one also turns with the front wheels. + */ + private static void searchLights(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + for (SearchLight light : def.searchLights) { + pose.pushPose(); + pose.translate(light.position().x(), light.position().y(), light.position().z()); + if (!light.fixed()) { + pose.rotateDegrees(Axis.YN, state.searchLightYaw - state.yaw + light.yaw()); + pose.rotateDegrees(Axis.XP, state.searchLightPitch + 90.0F - state.pitch + light.pitch()); + } else { + float steer = light.steering() ? -state.steer * light.steeringRotation() : 0.0F; + pose.rotateDegrees(Axis.YN, light.yaw() + steer); + pose.rotateDegrees(Axis.XP, 90.0F + light.pitch()); + } + Glow.cone(pose, collector, light.height(), light.width() / 2.0F, light.colorStart(), light.colorEnd()); + pose.popPose(); + } + } + + private static void bladeAngles(HelicopterEntity heli, HelicopterDefinition def, AircraftRenderState state, float partialTicks) { + if (state.bladeAngles.length != def.rotors.size()) { + state.bladeAngles = new float[def.rotors.size()][]; + } + for (int r = 0; r < def.rotors.size(); r++) { + RotorDefinition rotor = def.rotors.get(r); + int blades = Math.max(rotor.blades(), 1); + int step = rotor.bladeStep() > 0 ? rotor.bladeStep() : 1; + if (state.bladeAngles[r] == null || state.bladeAngles[r].length != blades) { + state.bladeAngles[r] = new float[blades]; + } + for (int b = 0; b < blades; b++) { + state.bladeAngles[r][b] = heli.bladeAngle(r, b, step, partialTicks); + } + } + } + + /** {@code renderRope}: a dark line straight down from each hook, in the vehicle's own frame. */ + private static void ropes(AircraftDefinition def, AircraftRenderState state, PoseStack pose, SubmitNodeCollector collector) { + int light = state.lightCoords; + float length = state.ropeLength; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(ROPE), (p, buffer) -> { + for (RepellingHook hook : def.repellingHooks) { + float x = (float) hook.position().x(); + float y = (float) hook.position().y(); + float z = (float) hook.position().z(); + // Two thin crossed strips, so the rope shows from every side. + ropeStrip(p, buffer, x - ROPE_WIDTH, z, x + ROPE_WIDTH, z, y, length, light); + ropeStrip(p, buffer, x, z - ROPE_WIDTH, x, z + ROPE_WIDTH, y, length, light); + } + }); + } + + private static void ropeStrip(PoseStack.Pose p, VertexConsumer buffer, float x0, float z0, float x1, float z1, float y, float length, int light) { + ropeVertex(p, buffer, x0, y, z0, 0.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y, z1, 1.0F, 0.0F, light); + ropeVertex(p, buffer, x1, y + length, z1, 1.0F, 1.0F, light); + ropeVertex(p, buffer, x0, y + length, z0, 0.0F, 1.0F, light); + } + + private static void ropeVertex(PoseStack.Pose p, VertexConsumer buffer, float x, float y, float z, float u, float v, int light) { + buffer.addVertex(p, x, y, z).setColor(0xFFFFFFFF).setUv(u, v).setOverlay(OverlayTexture.NO_OVERLAY).setLight(light).setNormal(p, 0.0F, 1.0F, 0.0F); + } + private static Identifier texture(String folder, String name) { return RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java new file mode 100644 index 0000000..6dfb9a4 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ChainRenderer.java @@ -0,0 +1,93 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * {@code MCH_RenderChain}: a line of links from the towed end to the end that pulls, one link + * every 0.95 blocks, each turned along the chain. + */ +public class ChainRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/chain.png"); + private static final double LINK = 0.95; + private static final int GREY = 0xFF808080; + + public ChainRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + /** Towed end, relative to the chain entity. */ + Vec3 from = Vec3.ZERO; + /** From the towed end to the end that pulls. */ + Vec3 span = Vec3.ZERO; + } + + /** The chain hangs over the end that pulls but reaches to the other end; keep both in view. */ + @Override + protected AABB getBoundingBoxForCulling(ChainEntity chain, float partialTicks) { + AABB box = super.getBoundingBoxForCulling(chain, partialTicks); + Entity towed = chain.towed(); + return towed != null ? box.minmax(towed.getInterpolatedBoundingBox(partialTicks)) : box; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ChainEntity chain, State state, float partialTicks) { + super.extractRenderState(chain, state, partialTicks); + Entity tow = chain.tow(); + Entity towed = chain.towed(); + if (tow == null || towed == null) { + state.span = Vec3.ZERO; + return; + } + Vec3 here = chain.getPosition(partialTicks); + Vec3 start = towed.getPosition(partialTicks); + state.from = start.subtract(here); + state.span = tow.getPosition(partialTicks).subtract(start); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "chain"); + double length = state.span.length(); + if (mesh != null && length > LINK) { + Vec3 step = state.span.scale(LINK / length); + float yaw = (float) Math.toDegrees(Math.atan2(step.z, step.x)); + float pitch = (float) Math.toDegrees(Math.atan2(step.y, Math.sqrt(step.x * step.x + step.z * step.z))); + int light = state.lightCoords; + pose.pushPose(); + pose.translate(state.from); + for (double left = length; left > LINK; left -= LINK) { + pose.translate(step); + pose.pushPose(); + pose.rotateDegrees(Axis.YP, -yaw); + pose.rotateDegrees(Axis.ZP, pitch); + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, GREY)); + pose.popPose(); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java new file mode 100644 index 0000000..e4a9dee --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/DraftingTableRenderer.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** {@code MCH_DraftingTableRenderer}: the original model, turned to the block's eighth of a circle. */ +public class DraftingTableRenderer implements BlockEntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/block/drafting_table.png"); + /** The original greyed the table a little. */ + private static final int TINT = 0xFFBFBFBF; + + public DraftingTableRenderer(BlockEntityRendererProvider.Context context) { + } + + public static class State extends BlockEntityRenderState { + int rotation; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(DraftingTableBlockEntity table, State state, float partialTicks, Vec3 cameraPosition, + ModelFeatureRenderer.@Nullable CrumblingOverlay breakProgress) { + BlockEntityRenderer.super.extractRenderState(table, state, partialTicks, cameraPosition, breakProgress); + state.rotation = table.getBlockState().getValue(DraftingTableBlock.ROTATION); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("blocks", "drafting_table"); + if (mesh == null) { + return; + } + pose.pushPose(); + pose.translate(0.5F, 0.0F, 0.5F); + pose.rotateDegrees(Axis.YP, -state.rotation * 45.0F + 180.0F); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + pose.popPose(); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java new file mode 100644 index 0000000..d4b8458 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/EquipmentRenderer.java @@ -0,0 +1,73 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; + +/** + * Equipment drawn as one model from the models root with the texture of the same name, turned to + * the entity's heading: the container, {@code MCH_RenderContainer}. + */ +public class EquipmentRenderer extends EntityRenderer { + private final String model; + private final Identifier texture; + private final float yOffset; + private final int tint; + + /** + * @param model the file name under {@code models/}, without extension + * @param yOffset how far the model sits below the entity's position + * @param tint ARGB the model is multiplied by; the original greyed the container a little + */ + public EquipmentRenderer(EntityRendererProvider.Context context, String model, float yOffset, int tint, float shadow) { + super(context); + this.model = model; + this.texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + model + ".png"); + this.yOffset = yOffset; + this.tint = tint; + this.shadowRadius = shadow; + } + + public static class State extends EntityRenderState { + float yaw; + float pitch; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(T entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.pitch = entity.getXRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", model); + if (mesh != null) { + pose.pushPose(); + pose.translate(0.0F, -yOffset, 0.0F); + pose.rotateDegrees(Axis.YP, -state.yaw); + pose.rotateDegrees(Axis.XP, state.pitch); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY, tint)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.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 807a460..aabf3a3 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 @@ -22,6 +22,7 @@ import net.minecraft.resources.Identifier; import net.minecraft.util.ARGB; import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; import net.minecraft.world.level.Level; import net.minecraft.world.level.LightLayer; import net.minecraft.world.phys.Vec3; @@ -44,7 +45,17 @@ public final class Glow { .withDepthStencilState(TEST_ONLY) .build(); - private static final RenderPipeline ENTITY_PIPELINE = RenderPipeline.builder(RenderPipelines.ENTITY_NO_LIGHTMAP_SNIPPET, RenderPipelines.EYES_SNIPPET) + /** + * Vanilla's translucent particles with depth writes off. A puff's clear edge would otherwise + * hide the puffs drawn after it, and a smoke cloud is mostly overlapping puffs. + */ + private static final RenderPipeline SMOKE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/smoke_particle")) + .withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)) + .withDepthStencilState(TEST_ONLY) + .build(); + + private static final RenderPipeline ENTITY_PIPELINE =RenderPipeline.builder(RenderPipelines.ENTITY_NO_LIGHTMAP_SNIPPET, RenderPipelines.EYES_SNIPPET) .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow")) .withColorTargetState(new ColorTargetState(BlendFunction.LIGHTNING)) .withDepthStencilState(TEST_ONLY) @@ -55,6 +66,11 @@ public final class Glow { public static final SingleQuadParticle.Layer PARTICLE_LAYER = new SingleQuadParticle.Layer( true, TextureAtlas.LOCATION_PARTICLES, PARTICLE_PIPELINE, RenderPipelines.OIT_PARTICLE); + /** The particle layer for {@code mcheli:smoke}. */ + @SuppressWarnings("deprecation") + public static final SingleQuadParticle.Layer SMOKE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, SMOKE_PIPELINE, RenderPipelines.OIT_PARTICLE); + private static final RenderType HALO = RenderType.create(MCHeli.MOD_ID + "_glow", RenderSetup.builder(ENTITY_PIPELINE) .setOitPipelines(RenderPipelines.OIT_EYES) .withTexture("Sampler0", TEXTURE) @@ -67,13 +83,14 @@ 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. + * Hands our pipelines to Iris, so a shader pack draws them with a matching program: the halos + * and tracers as emissive entities, the glow and smoke particles as translucent particles. + * Called once the mods are loaded; without Iris it does nothing. */ public static void assignShaderPrograms() { IrisCompat.assign(ENTITY_PIPELINE, "EMISSIVE_ENTITIES"); IrisCompat.assign(PARTICLE_PIPELINE, "PARTICLES_TRANSLUCENT"); + IrisCompat.assign(SMOKE_PIPELINE, "PARTICLES_TRANSLUCENT"); } /** @@ -152,6 +169,25 @@ public static void streak(PoseStack pose, SubmitNodeCollector collector, Vec3 to }); } + /** + * A search light's beam, {@code renderLight}: a cone {@code height} long along +Y to a disc + * {@code radius} across, fading from {@code start} at the lamp to {@code end} at the rim (ARGB). + */ + public static void cone(PoseStack pose, SubmitNodeCollector collector, float height, float radius, int start, int end) { + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + int segments = 24; + for (int i = 0; i < segments; i++) { + float a0 = i * Mth.TWO_PI / segments; + float a1 = (i + 1) * Mth.TWO_PI / segments; + // A quad with the apex twice is the fan's triangle; the texture's bright middle colours it. + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, 0.0F, 0.0F, 0.0F, 0.5F, 0.5F, start); + vertex(buffer, p, Mth.sin(a0) * radius, height, Mth.cos(a0) * radius, 0.5F, 0.5F, end); + vertex(buffer, p, Mth.sin(a1) * radius, height, Mth.cos(a1) * radius, 0.5F, 0.5F, end); + } + }); + } + private static int alpha(float strength) { return Math.clamp(Math.round(strength * 255.0F), 0, 255); } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java new file mode 100644 index 0000000..8762802 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GltdRenderer.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.GltdEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** + * {@code MCH_RenderGLTD}: the tripod, its head turned to where the viewer aims, and a faint red + * laser out of the head while someone uses it. The viewer's own head part is left out in first + * person, where it would fill the view. + */ +public class GltdRenderer extends EntityRenderer { + private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gltd.png"); + private static final float HEAD_PIVOT = 0.45F; + private static final double LASER_LENGTH = 100.0; + private static final int LASER = 0xFF2020; + + public GltdRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.5F; + } + + public static class State extends EntityRenderState { + float yaw; + float headYaw; + float headPitch; + boolean drawHead = true; + boolean laser; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(GltdEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + Entity viewer = entity.getFirstPassenger(); + Minecraft mc = Minecraft.getInstance(); + state.laser = viewer != null; + state.drawHead = viewer == null || viewer != mc.player || !mc.options.getCameraType().isFirstPerson(); + state.headYaw = viewer != null ? viewer.getYRot(partialTicks) : state.yaw; + state.headPitch = viewer != null ? viewer.getXRot(partialTicks) : 0.0F; + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = MeshCache.INSTANCE.get("", "gltd"); + if (mesh != null) { + int light = state.lightCoords; + pose.pushPose(); + pose.rotateDegrees(Axis.YP, -state.yaw); + draw(mesh.part("$body"), pose, collector, light); + pose.popPose(); + if (state.drawHead) { + pose.pushPose(); + pose.translate(0.0F, HEAD_PIVOT, 0.0F); + pose.rotateDegrees(Axis.YP, -state.headYaw); + pose.rotateDegrees(Axis.XP, state.headPitch); + pose.translate(0.0F, -HEAD_PIVOT, 0.0F); + draw(mesh.part("$head"), pose, collector, light); + pose.popPose(); + } + } + if (state.laser) { + float yaw = state.headYaw * Mth.DEG_TO_RAD; + float pitch = state.headPitch * Mth.DEG_TO_RAD; + Vec3 look = new Vec3(-Mth.sin(yaw) * Mth.cos(pitch), -Mth.sin(pitch), Mth.cos(yaw) * Mth.cos(pitch)); + Vec3 head = new Vec3(0.0, HEAD_PIVOT + 0.2, 0.0); + Vec3 toCamera = new Vec3(state.x, state.y, state.z).add(head).subtract(camera.pos); + pose.pushPose(); + pose.translate(head); + // Brightest at the tripod, fading out along the beam. + Glow.streak(pose, collector, toCamera, look.scale(LASER_LENGTH), 0.02F, LASER, 0.25F); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + private static void draw(List groups, PoseStack pose, SubmitNodeCollector collector, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(TEXTURE), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY)); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java index 8f7242c..0c2f9a3 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java @@ -114,7 +114,8 @@ void submitAll() { // Kind specific parts private void rotors(HelicopterDefinition heli) { - for (RotorDefinition rotor : heli.rotors) { + for (int r = 0; r < heli.rotors.size(); r++) { + RotorDefinition rotor = heli.rotors.get(r); List blade = part(rotor.part()); if (blade.isEmpty() || isZero(rotor.axis())) { continue; @@ -125,7 +126,8 @@ private void rotors(HelicopterDefinition heli) { pose.pushPose(); Vector3 p = rotor.position(); translate(p, 1.0F); - rotate(state.rotorAngle + i * step, rotor.axis()); + float[] angles = r < state.bladeAngles.length ? state.bladeAngles[r] : null; + rotate(angles != null && i < angles.length ? angles[i] : state.rotorAngle + i * step, rotor.axis()); if (!rotor.legacyPivot()) { translate(p, -1.0F); } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java new file mode 100644 index 0000000..504dcac --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ThrowableRenderer.java @@ -0,0 +1,55 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +/** {@code MCH_RenderThrowable}: the grenade's own model from {@code models/throwable}. */ +public class ThrowableRenderer extends EntityRenderer { + public ThrowableRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + String name = ""; + float yaw; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(ThrowableEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.name = entity.definitionName(); + state.yaw = entity.getYRot(partialTicks); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Mesh mesh = state.name.isEmpty() ? null : MeshCache.INSTANCE.get("throwable", state.name); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/throwable/" + state.name + ".png"); + pose.pushPose(); + pose.rotateDegrees(Axis.YP, -state.yaw); + int light = state.lightCoords; + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, light, OverlayTexture.NO_OVERLAY)); + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java new file mode 100644 index 0000000..be6faae --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/render/UavStationRenderer.java @@ -0,0 +1,95 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshGroup; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import java.util.List; + +/** + * The UAV station and the portable controller, {@code MCH_RenderUavStation}. The station's screen + * lights up while a drone is flown from it; the controller's lid, keyboard half and display fold + * open as it is opened, and light up once fully open. + */ +public class UavStationRenderer extends EntityRenderer { + private static final String[] MODEL = {"uav_station", "uav_portable_controller"}; + /** The original greyed both a little. */ + private static final int TINT = 0xFFBFBFBF; + + public UavStationRenderer(EntityRendererProvider.Context context) { + super(context); + shadowRadius = 1.0F; + } + + public static class State extends EntityRenderState { + float yaw; + int kind; + boolean lit; + float cover; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(UavStationEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.yaw = entity.getYRot(partialTicks); + state.kind = entity.kind(); + state.cover = entity.cover(partialTicks); + state.lit = state.kind == UavStationEntity.PORTABLE ? state.cover > 0.95F : entity.isLinked(); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + int index = state.kind == UavStationEntity.PORTABLE ? 1 : 0; + Mesh mesh = MeshCache.INSTANCE.get("", MODEL[index]); + if (mesh != null) { + Identifier texture = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + MODEL[index] + (state.lit ? "_on" : "") + ".png"); + pose.pushPose(); + pose.rotateDegrees(Axis.YP, -state.yaw); + int light = state.lightCoords; + if (index == 0) { + draw(pose, collector, texture, mesh.groups(), light); + } else { + draw(pose, collector, texture, mesh.part("$body"), light); + folding(pose, collector, texture, mesh.part("$cover"), state.cover * 60.0F, -0.1812F, -0.3186F, light); + folding(pose, collector, texture, mesh.part("$laptop_cover"), state.cover * 95.0F, -0.1808F, -0.0422F, light); + folding(pose, collector, texture, mesh.part("$display"), state.cover * -85.0F, -0.1807F, 0.2294F, light); + } + pose.popPose(); + } + super.submit(state, pose, collector, camera); + } + + /** {@code renderRotPart}: turns a part about the x axis through its hinge. */ + private static void folding(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List part, + float angle, float y, float z, int light) { + pose.pushPose(); + pose.translate(0.0F, y, z); + pose.rotateDegrees(Axis.XN, angle); + pose.translate(0.0F, -y, -z); + draw(pose, collector, texture, part, light); + pose.popPose(); + } + + private static void draw(PoseStack pose, SubmitNodeCollector collector, Identifier texture, List groups, int light) { + if (!groups.isEmpty()) { + collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), + (p, buffer) -> MeshRenderer.emit(groups, p, buffer, light, OverlayTexture.NO_OVERLAY, TINT)); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java new file mode 100644 index 0000000..cca4746 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/DraftingTableScreen.java @@ -0,0 +1,324 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponGroup; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.network.DraftingTablePacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.recipebook.RecipeCollection; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.context.ContextMap; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.StackedItemContents; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.display.RecipeDisplay; +import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; +import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplayContext; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The drafting table's recipe browser, {@code MCH_DraftingTableGui}. Pick a list (helicopters, + * planes, tanks, ground vehicles or equipment), step through its recipes, and Create makes the one + * shown into the output slot. The right page shows the original's illustrated description for + * equipment and a vehicle's numbers for vehicles; the original turned a 3D model there instead. + * + *

The recipes come from the player's recipe book, which holds every MCHeli recipe from login. + */ +public class DraftingTableScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/drafting_table.png"); + private static final int TEXTURE_SIZE = 512; + /** The description pages take this part of their 512 square texture. */ + private static final int PAGE_WIDTH = 340; + private static final int PAGE_HEIGHT = 380; + private static final int PAGE_X = 210; + private static final int PAGE_Y = 20; + private static final int GRID_X = 121; + private static final int GRID_Y = 34; + private static final int TEXT = 0xFF6A3A00; + private static final int WHITE = 0xFFFFFFFF; + private static final String[] LISTS = {"helicopter", "plane", "vehicle", "tank", "item"}; + + private final List> lists = new ArrayList<>(); + private int list; + private int index; + private int page; + private int pageCount; + private @Nullable Button create; + private @Nullable Button previous; + private @Nullable Button next; + private @Nullable Button previousPage; + private @Nullable Button nextPage; + + public DraftingTableScreen(DraftingTableMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 400, 240); + inventoryLabelX = 30; + inventoryLabelY = 130; + } + + @Override + protected void init() { + super.init(); + collectRecipes(); + for (int i = 0; i < LISTS.length; i++) { + int which = i; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.list." + LISTS[i]), b -> selectList(which)) + .bounds(leftPos + 20, topPos + 20 + i * 20, 90, 20).build()); + } + create = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.create"), b -> create()) + .bounds(leftPos + 120, topPos + 89, 50, 20).build()); + previous = addRenderableWidget(Button.builder(Component.literal("<<"), b -> step(-1)).bounds(leftPos + 120, topPos + 111, 36, 20).build()); + next = addRenderableWidget(Button.builder(Component.literal(">>"), b -> step(1)).bounds(leftPos + 155, topPos + 111, 35, 20).build()); + previousPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.previous_page"), b -> turnPage(-1)) + .bounds(leftPos + 210, topPos + 210, 60, 20).build()); + nextPage = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.drafting_table.next_page"), b -> turnPage(1)) + .bounds(leftPos + 270, topPos + 210, 60, 20).build()); + selectList(list); + } + + /** Every MCHeli recipe the recipe book holds, sorted into the five lists by what it makes. */ + private void collectRecipes() { + lists.clear(); + for (int i = 0; i < LISTS.length; i++) { + lists.add(new ArrayList<>()); + } + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + for (RecipeCollection collection : minecraft.player.getRecipeBook().getCollections()) { + for (RecipeDisplayEntry entry : collection.getRecipes()) { + ItemStack result = entry.display().result().resolveForFirstStack(context); + if (!result.isEmpty() && BuiltInRegistries.ITEM.getKey(result.getItem()).getNamespace().equals(MCHeli.MOD_ID)) { + lists.get(listOf(result.getItem())).add(entry); + } + } + } + for (List entries : lists) { + entries.sort(Comparator.comparing(e -> e.display().result().resolveForFirstStack(context).getHoverName().getString())); + } + } + + private static int listOf(Item item) { + if (item == ModItems.HELICOPTER.get()) { + return 0; + } + if (item == ModItems.PLANE.get()) { + return 1; + } + if (item == ModItems.VEHICLE.get()) { + return 2; + } + if (item == ModItems.TANK.get()) { + return 3; + } + return 4; + } + + private void selectList(int which) { + list = which; + index = 0; + showRecipe(); + } + + private void step(int direction) { + int size = lists.get(list).size(); + if (size > 0) { + index = Math.floorMod(index + direction, size); + showRecipe(); + } + } + + private void turnPage(int direction) { + page = Math.clamp(page + direction, 0, Math.max(pageCount - 1, 0)); + updateButtons(); + } + + private void showRecipe() { + page = 0; + ItemStack result = result(); + pageCount = descriptionPages(result); + updateButtons(); + } + + private @Nullable RecipeDisplayEntry current() { + List entries = lists.get(list); + return entries.isEmpty() ? null : entries.get(Math.min(index, entries.size() - 1)); + } + + private ItemStack result() { + RecipeDisplayEntry entry = current(); + return entry != null ? entry.display().result().resolveForFirstStack(SlotDisplayContext.fromLevel(minecraft.level)) : ItemStack.EMPTY; + } + + /** The original's description pages for equipment: {@code drafting_table_desc/_.png}. */ + private int descriptionPages(ItemStack result) { + if (result.isEmpty() || AircraftItem.vehicleOf(result) != null) { + return 0; + } + String name = descriptionName(result); + int pages = 0; + while (pages < 10 && minecraft.getResourceManager().getResource(pageTexture(name, pages)).isPresent()) { + pages++; + } + return pages; + } + + private static String descriptionName(ItemStack result) { + if (result.getItem() == ModItems.THROWABLE.get()) { + String throwable = result.get(ModDataComponents.THROWABLE.get()); + return throwable != null ? throwable : "throwable"; + } + return BuiltInRegistries.ITEM.getKey(result.getItem()).getPath(); + } + + private static Identifier pageTexture(String name, int page) { + return Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/drafting_table_desc/" + name + "_" + page + ".png"); + } + + private boolean canCreate() { + RecipeDisplayEntry entry = current(); + if (entry == null || menu.getSlot(menu.slots.size() - 1).hasItem() && !minecraft.player.getAbilities().instabuild) { + return false; + } + if (minecraft.player.getAbilities().instabuild) { + return true; + } + StackedItemContents contents = new StackedItemContents(); + minecraft.player.getInventory().fillStackedContents(contents); + return entry.canCraft(contents); + } + + private void create() { + RecipeDisplayEntry entry = current(); + if (entry != null) { + ModNetwork.sendToServer(new DraftingTablePacket(entry.id())); + } + } + + private void updateButtons() { + if (create == null || previous == null || next == null || previousPage == null || nextPage == null) { + return; + } + boolean any = current() != null; + create.active = any && canCreate(); + previous.active = lists.get(list).size() > 1; + next.active = lists.get(list).size() > 1; + previousPage.visible = pageCount > 1; + nextPage.visible = pageCount > 1; + previousPage.active = page > 0; + nextPage.active = page < pageCount - 1; + } + + @Override + protected void containerTick() { + super.containerTick(); + updateButtons(); + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, TEXTURE_SIZE, TEXTURE_SIZE); + if (pageCount > 0) { + String name = descriptionName(result()); + graphics.blit(RenderPipelines.GUI_TEXTURED, pageTexture(name, page), leftPos + PAGE_X, topPos + PAGE_Y, 0.0F, 0.0F, + PAGE_WIDTH / 2, PAGE_HEIGHT / 2, PAGE_WIDTH, PAGE_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE); + } + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, TEXT, false); + RecipeDisplayEntry entry = current(); + if (entry == null) { + graphics.text(font, Component.translatable("gui.mcheli.drafting_table.empty"), 120, 20, TEXT, false); + return; + } + ItemStack result = result(); + graphics.text(font, font.plainSubstrByWidth(result.getHoverName().getString(), 85), 120, 20, WHITE, true); + graphics.text(font, (index + 1) + " / " + lists.get(list).size(), 120, 134, TEXT, false); + ingredients(graphics, entry.display(), mouseX, mouseY); + graphics.item(result, 178, 52); + if (pageCount == 0) { + details(graphics, result); + } + } + + /** {@code drawItemRecipe}: the 3x3 grid, each slot cycling through what it accepts once a second. */ + private void ingredients(GuiGraphicsExtractor graphics, RecipeDisplay display, int mouseX, int mouseY) { + ContextMap context = SlotDisplayContext.fromLevel(minecraft.level); + List slots; + int width; + switch (display) { + case ShapedCraftingRecipeDisplay shaped -> { + slots = shaped.ingredients(); + width = shaped.width(); + } + case ShapelessCraftingRecipeDisplay shapeless -> { + slots = shapeless.ingredients(); + width = 3; + } + default -> { + return; + } + } + int tick = (int) (minecraft.level.getGameTime() / 20); + for (int i = 0; i < slots.size() && i < 9; i++) { + List options = slots.get(i).resolveForStacks(context); + if (options.isEmpty()) { + continue; + } + ItemStack stack = options.get(tick % options.size()); + int x = GRID_X + (i % width) * 18; + int y = GRID_Y + (i / width) * 18; + graphics.item(stack, x, y); + if (mouseX - leftPos >= x && mouseX - leftPos < x + 16 && mouseY - topPos >= y && mouseY - topPos < y + 16) { + graphics.setTooltipForNextFrame(font, stack, mouseX - leftPos, mouseY - topPos); + } + } + } + + /** The vehicle's numbers, where the original turned its model. */ + private void details(GuiGraphicsExtractor graphics, ItemStack result) { + String name = AircraftItem.vehicleOf(result); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + return; + } + List lines = new ArrayList<>(); + lines.add(Component.translatable("gui.mcheli.drafting_table.hp", def.maxHp)); + lines.add(Component.translatable("gui.mcheli.drafting_table.speed", "%.2f".formatted(def.speed))); + lines.add(Component.translatable("gui.mcheli.drafting_table.seats", def.seatCount())); + lines.add(Component.translatable("gui.mcheli.drafting_table.fuel", def.maxFuel)); + lines.add(Component.translatable("gui.mcheli.drafting_table.weapons")); + for (WeaponGroup group : def.weaponGroups) { + WeaponDefinition weapon = MCHeli.content().weapon(group.weaponType()); + String weaponName = weapon != null && weapon.displayName != null ? weapon.displayName : group.weaponType(); + lines.add(Component.literal(" " + weaponName + (group.mounts().size() > 1 ? " x" + group.mounts().size() : ""))); + } + int y = PAGE_Y + 20; + for (Component line : lines) { + if (y > PAGE_Y + 180) { + break; + } + graphics.text(font, font.split(line, 170).getFirst(), PAGE_X, y, TEXT, false); + y += 10; + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java new file mode 100644 index 0000000..8578f94 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/TeamManagerScreen.java @@ -0,0 +1,135 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.TeamPacket; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.TeamColor; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The team manager, {@code MCH_GuiScoreboard_Main} and {@code MCH_GuiScoreboard_CreateTeam}, for + * operators setting up a match: switch PvP, shuffle everyone into the teams, make a team, send + * everyone to their spawn point and clear away every vehicle. The teams and their players are + * listed on the right. + */ +public class TeamManagerScreen extends Screen { + private static final int WIDTH = 150; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFA0A0A0; + + private @Nullable EditBox name; + private int color = TeamColor.RED.ordinal(); + private boolean friendlyFire; + private @Nullable Button colorButton; + private @Nullable Button friendlyFireButton; + private @Nullable Button createButton; + + public TeamManagerScreen() { + super(Component.translatable("gui.mcheli.team.title")); + } + + @Override + protected void init() { + int left = width / 2 - WIDTH - 10; + int y = 40; + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.pvp"), b -> send(TeamPacket.Action.PVP)) + .bounds(left, y, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.shuffle"), b -> send(TeamPacket.Action.SHUFFLE)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.shuffle_hint"))) + .bounds(left, y + 22, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.jump_spawn"), b -> send(TeamPacket.Action.JUMP_TO_SPAWN)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.jump_spawn_hint"))) + .bounds(left, y + 44, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.destroy_all"), b -> send(TeamPacket.Action.DESTROY_ALL)) + .tooltip(Tooltip.create(Component.translatable("gui.mcheli.team.destroy_all_hint"))) + .bounds(left, y + 66, WIDTH, 20).build()); + + int create = y + 104; + name = addRenderableWidget(new EditBox(font, left, create, WIDTH, 20, Component.translatable("gui.mcheli.team.name"))); + name.setMaxLength(16); + name.setHint(Component.translatable("gui.mcheli.team.name")); + name.setResponder(text -> updateButtons()); + colorButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + color = (color + 1) % TeamColor.VALUES.size(); + updateButtons(); + }).bounds(left, create + 22, WIDTH, 20).build()); + friendlyFireButton = addRenderableWidget(Button.builder(Component.empty(), b -> { + friendlyFire = !friendlyFire; + updateButtons(); + }).bounds(left, create + 44, WIDTH, 20).build()); + createButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.team.create"), b -> { + ModNetwork.sendToServer(new TeamPacket(TeamPacket.Action.CREATE_TEAM, name.getValue(), color, friendlyFire)); + name.setValue(""); + }).bounds(left, create + 66, WIDTH, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 50, height - 28, 100, 20).build()); + updateButtons(); + } + + private void send(TeamPacket.Action action) { + ModNetwork.sendToServer(new TeamPacket(action)); + } + + private void updateButtons() { + if (colorButton == null || friendlyFireButton == null || createButton == null || name == null) { + return; + } + TeamColor teamColor = TeamColor.VALUES.get(color); + colorButton.setMessage(Component.translatable("gui.mcheli.team.color", + Component.literal(teamColor.getSerializedName()).withColor(teamColor.textColor().getValue()))); + friendlyFireButton.setMessage(Component.translatable(friendlyFire ? "gui.mcheli.team.friendly_fire_on" : "gui.mcheli.team.friendly_fire_off")); + String value = name.getValue().trim(); + createButton.active = !value.isEmpty() && !value.contains(" "); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(font, title, width / 2, 15, TEXT); + int left = width / 2 - WIDTH - 10; + graphics.text(font, Component.translatable("gui.mcheli.team.new_team"), left, 132, DIM); + teams(graphics, width / 2 + 10, 40); + } + + /** The scoreboard's teams and who is in them, as this client sees the scoreboard. */ + private void teams(GuiGraphicsExtractor graphics, int x, int y) { + if (minecraft.level == null) { + return; + } + List teams = new ArrayList<>(minecraft.level.getScoreboard().getPlayerTeams()); + if (teams.isEmpty()) { + graphics.text(font, Component.translatable("gui.mcheli.team.none"), x, y, DIM); + return; + } + int bottom = height - 40; + for (PlayerTeam team : teams) { + if (y > bottom) { + break; + } + graphics.text(font, team.getFormattedDisplayName(), x, y, TEXT); + y += 11; + for (String player : team.getPlayers()) { + if (y > bottom) { + break; + } + graphics.text(font, " " + player, x, y, DIM); + y += 10; + } + y += 4; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java new file mode 100644 index 0000000..0a9c3da --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/client/screen/UavStationScreen.java @@ -0,0 +1,94 @@ +package com.jnhprojects.mcheli.client.screen; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_GuiUavStation}: the slot for the drone, the launch offset + * per axis with its buttons, and Continue to take the last drone flown from here again. + */ +public class UavStationScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/gui/uav_station.png"); + private static final String[] AXES = {"X", "Y", "Z"}; + private static final int WHITE = 0xFFFFFFFF; + private static final int RED = 0xFFFF0000; + + private @Nullable Button continueButton; + + public UavStationScreen(UavStationMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 176, 166); + inventoryLabelY = imageHeight - 94; + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 5; + int y = height / 2 - 76; + for (int axis = 0; axis < 3; axis++) { + for (int step = 0; step < UavStationMenu.STEPS.length; step++) { + int s = UavStationMenu.STEPS[step]; + int id = axis * 4 + step; + addRenderableWidget(Button.builder(Component.literal((s > 0 ? "+" : "") + s), b -> press(id)) + .bounds(x + step * 20, y + axis * 22, 20, 20).build()); + } + } + continueButton = addRenderableWidget(Button.builder(Component.translatable("gui.mcheli.uav_station.continue"), b -> press(UavStationMenu.BUTTON_CONTINUE)) + .bounds(x - 77, y + 44, 50, 20).build()); + continueButton.active = menu.canContinue(); + } + + @Override + protected void containerTick() { + super.containerTick(); + if (continueButton != null) { + continueButton.active = menu.canContinue(); + } + } + + private void press(int button) { + if (minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, button); + } + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractBackground(graphics, mouseX, mouseY, a); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0.0F, 0.0F, imageWidth, imageHeight, 256, 256); + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + UavStationEntity station = menu.station(); + ItemStack stack = menu.getSlot(0).getItem(); + AircraftDefinition def = stack.isEmpty() ? null : UavStationEntity.definitionOf(stack); + Component label; + int color = WHITE; + if (!stack.isEmpty() && (def == null || !def.uav)) { + label = Component.translatable("gui.mcheli.uav_station.not_uav"); + color = RED; + } else if (station != null && station.kind() == UavStationEntity.PORTABLE && def != null && !def.smallUav) { + label = Component.translatable("gui.mcheli.uav_station.small_only"); + color = RED; + } else { + label = title; + } + graphics.text(font, label, 8, 6, color, false); + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, WHITE, false); + for (int axis = 0; axis < 3; axis++) { + graphics.text(font, String.format("%s.%+d", AXES[axis], menu.offset(axis)), 58, 15 + axis * 22, WHITE, false); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java index 4893846..3a2151d 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,6 +12,7 @@ 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.ChainItem; import com.jnhprojects.mcheli.item.FuelItem; import com.jnhprojects.mcheli.item.WrenchItem; import com.jnhprojects.mcheli.menu.VehicleMenu; @@ -19,6 +20,7 @@ import com.jnhprojects.mcheli.physics.AircraftRotation; import com.jnhprojects.mcheli.physics.CrashSpin; import com.jnhprojects.mcheli.physics.GroundImpact; +import com.jnhprojects.mcheli.registry.ModEntities; import com.jnhprojects.mcheli.registry.ModItems; import com.jnhprojects.mcheli.supply.SupplyRules; import com.jnhprojects.mcheli.weapon.Explosions; @@ -26,6 +28,7 @@ import com.jnhprojects.mcheli.weapon.WeaponSet; import com.mojang.serialization.Codec; import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; import net.minecraft.core.particles.ParticleTypes; import net.minecraft.network.chat.Component; import net.minecraft.network.syncher.EntityDataAccessor; @@ -43,14 +46,13 @@ import net.minecraft.world.SimpleMenuProvider; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.damagesource.DamageTypes; -import net.minecraft.world.effect.MobEffectInstance; -import net.minecraft.world.effect.MobEffects; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityDimensions; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.InterpolationHandler; import net.minecraft.world.entity.LinearInterpolationHandler; import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.MoverType; import net.minecraft.world.entity.Pose; import net.minecraft.world.entity.player.Player; @@ -109,6 +111,13 @@ public abstract class AircraftEntity extends VehicleEntity { private static final EntityDataAccessor DATA_FLARE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); private static final EntityDataAccessor DATA_SINKING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); private static final EntityDataAccessor DATA_FUEL = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + /** The UAV station a drone is flown from, by entity id, or 0. */ + private static final EntityDataAccessor DATA_STATION = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_REPELLING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SEARCH_LIGHT = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + + /** Marks a player flying a drone from a station, so a stale hide can be undone at login. */ + public static final String REMOTE_PILOT_TAG = "mcheli_remote_pilot"; // Bits of DATA_PARTS, the same assignment the original used for its part status word. protected static final int PART_CANOPY = 0; @@ -151,6 +160,17 @@ public abstract class AircraftEntity extends VehicleEntity { private int pendingSeat = EMPTY; private final Map savedSeats = new HashMap<>(); private final Map lastSeats = new HashMap<>(); + /** The station a drone is flown from, and where its pilot goes back to. Server only. */ + private @Nullable UUID station; + private @Nullable Vec3 stationReturn; + /** Remote pilots leaving this tick, by entity id, with where they come back down. */ + private final Map remoteReturns = new HashMap<>(); + /** How far the ropes hang below their hooks, as a negative number; both sides let them out. */ + private float ropeLength; + private int repellingTicks; + private int lastRope; + /** Passengers are leaving one by one under parachutes. Server only. */ + private boolean droppingTroops; private @Nullable VehicleWeapons weapons; private int @Nullable [] savedAmmo; @@ -160,8 +180,6 @@ 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. */ @@ -230,6 +248,9 @@ protected void defineSynchedData(SynchedEntityData.Builder builder) { builder.define(DATA_FLARE, false); builder.define(DATA_SINKING, false); builder.define(DATA_FUEL, 0); + builder.define(DATA_STATION, 0); + builder.define(DATA_REPELLING, false); + builder.define(DATA_SEARCH_LIGHT, false); } // Definition @@ -364,6 +385,11 @@ public boolean hasFuel() { return maxFuel() <= 0 || fuel() > 1; } + /** Sets the tank outright; a drone leaves its station with 5%. Server only. */ + public void setFuelLevel(int fuel) { + entityData.set(DATA_FUEL, Math.clamp(fuel, 0, Math.max(maxFuel(), 0))); + } + /** * Pours up to {@code amount} into the tank. Server only. * @@ -390,33 +416,29 @@ public void setNightVision(Player player, boolean on) { nightVisionSeats |= 1 << seat; } else if (hasNightVision(seat)) { nightVisionSeats &= ~(1 << seat); - endNightVision(player); + NightVision.end(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. - */ + /** Keeps night vision on for everyone in the crew who switched it on, see {@link NightVision}. */ private void tickNightVision() { if (nightVisionSeats == 0) { return; } if (isDestroyed()) { for (int seat = 0; seat < seatIds.length; seat++) { - if (occupant(seat) instanceof LivingEntity crew) { - endNightVision(crew); + Entity crew = occupant(seat); + if (crew != null) { + NightVision.end(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)); - } + Entity crew = occupant(seat); + if (hasNightVision(seat) && crew != null) { + NightVision.topUp(crew); } } } @@ -426,16 +448,6 @@ 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 @@ -704,6 +716,7 @@ public void tick() { super.tick(); ticksAlive++; lastSeats.clear(); + remoteReturns.clear(); if (modeSwitchCooldown > 0) { modeSwitchCooldown--; } @@ -733,12 +746,21 @@ public void tick() { inventory.pourFuelCans(ticksAlive); } tickNightVision(); + tickStation(); } + tickRopes(def); if (isSimulatingHere()) { if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { input.clear(); } + if (isRepelling()) { + // {@code MCH_AircraftClientTickHandler}: the pilot holds a hover while the ropes are out. + input.throttleUp = false; + input.throttleDown = false; + input.left = false; + input.right = false; + } if (!hasFuel()) { // An empty tank: the throttle runs down by itself, as with the pilot's hand off it. input.throttleUp = false; @@ -783,6 +805,7 @@ public void tick() { } if (level().isClientSide()) { damageSmoke(def); + VehicleParticles.tick(this, def); } else { tickImpacts(def, groundedBefore, fallBefore); } @@ -1169,7 +1192,8 @@ private void decodeSeats(String encoded) { public void switchSeat(Entity entity) { AircraftDefinition def = definition(); int current = seatOf(entity); - if (def == null || current < 0 || current >= def.seatCount()) { + if (def == null || current < 0 || current >= def.seatCount() || current == 0 && station != null) { + // A remote pilot stays at the controls; they are not really aboard. return; } int count = def.seatCount(); @@ -1190,6 +1214,120 @@ public void switchSeat(Entity entity) { assignSeat(entity, current); } + // UAV stations + + /** Whether a player at a station could take this drone's controls now. */ + public boolean canTakeRemotePilot() { + AircraftDefinition def = definition(); + return def != null && def.uav && !def.targetDrone && !isDestroyed() && isSeatFree(0); + } + + /** + * Seats {@code player}, who sits at {@code from}, at this drone's controls. The pilot is hidden + * and takes no harm while flying, since in the original they never left the station. Server only. + */ + public boolean takeRemotePilot(ServerPlayer player, UavStationEntity from) { + if (!canTakeRemotePilot()) { + return false; + } + pendingSeat = 0; + boolean seated = player.startRiding(this, true, true); + pendingSeat = EMPTY; + if (!seated) { + return false; + } + station = from.getUUID(); + stationReturn = from.returnPosition(); + entityData.set(DATA_STATION, from.getId()); + player.addTag(REMOTE_PILOT_TAG); + player.setInvisible(true); + player.setPermanentlyInvulnerable(true); + return true; + } + + /** Sends the remote pilot back to the station, if one is flying. Server only. */ + public void releaseRemotePilot() { + Entity pilot = occupant(0); + if (station != null && pilot != null) { + pilot.stopRiding(); + } + } + + /** The station this drone is flown from, as the client sees it. */ + public @Nullable UavStationEntity stationEntity() { + return level().getEntity(entityData.get(DATA_STATION)) instanceof UavStationEntity s ? s : null; + } + + /** Flat distance to the station this drone is flown from, or -1 without one. */ + public double stationDistance() { + UavStationEntity from = stationEntity(); + if (from == null) { + return -1.0; + } + double dx = getX() - from.getX(); + double dz = getZ() - from.getZ(); + return Math.sqrt(dx * dx + dz * dz); + } + + /** Whether {@code entity} flies this drone from a station rather than sitting in it. */ + public boolean isRemotePilot(Entity entity) { + return (station != null || entityData.get(DATA_STATION) != 0) && occupant(0) == entity; + } + + private void endRemoteControl(Entity pilot) { + UavStationEntity from = level() instanceof ServerLevel server && station != null + && server.getEntity(station) instanceof UavStationEntity s ? s : null; + Vec3 home = from != null ? from.returnPosition() : stationReturn; + if (home != null) { + remoteReturns.put(pilot.getId(), home); + } + clearRemotePilot(pilot); + if (from != null) { + from.setLinked(false); + } + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + } + + /** Undoes what {@link #takeRemotePilot} did to the player. */ + public static void clearRemotePilot(Entity pilot) { + if (pilot.removeTag(REMOTE_PILOT_TAG)) { + pilot.setInvisible(false); + pilot.setPermanentlyInvulnerable(false); + } + } + + /** + * {@code updateUAV}: the link breaks when the station is gone, and flying out of its range + * loses the drone, as in the original. + */ + private void tickStation() { + if (station == null || !(level() instanceof ServerLevel server)) { + return; + } + Entity pilot = occupant(0); + if (pilot == null) { + station = null; + stationReturn = null; + entityData.set(DATA_STATION, 0); + return; + } + UavStationEntity from = server.getEntity(station) instanceof UavStationEntity s && s.isAlive() ? s : null; + if (from == null) { + pilot.stopRiding(); + return; + } + entityData.set(DATA_STATION, from.getId()); + if (!from.inRange(this) && !isDestroyed()) { + if (pilot instanceof Player player) { + player.sendOverlayMessage(Component.translatable("message.mcheli.uav.lost")); + } + pilot.stopRiding(); + hurtServer(server, damageSources().fellOutOfWorld(), hp() + 10.0F); + } + } + @Override public @Nullable LivingEntity getControllingPassenger() { // A wreck is nobody's to fly; the server lets it fall, so its crash is seen the same way by everyone. @@ -1205,8 +1343,13 @@ protected boolean canAddPassenger(Entity passenger) { if (pendingSeat >= 0) { return isSeatFree(pendingSeat); } + // Coming back aboard after a reload, a remote pilot included. + Integer saved = savedSeats.get(passenger.getUUID()); + if (saved != null && isSeatFree(saved)) { + return true; + } // Cargo only boards through a rack, never into a crew seat. - return !(passenger instanceof AircraftEntity) && firstFreeCrewSeat() >= 0; + return !(passenger instanceof AircraftEntity || passenger instanceof ContainerEntity) && firstFreeCrewSeat() >= 0; } @Override @@ -1234,7 +1377,7 @@ protected void removePassenger(Entity passenger) { if (seat >= 0) { if (hasNightVision(seat)) { nightVisionSeats &= ~(1 << seat); - endNightVision(passenger); + NightVision.end(passenger); } lastSeats.put(passenger.getId(), seat); seatIds[seat] = EMPTY; @@ -1245,6 +1388,9 @@ protected void removePassenger(Entity passenger) { } if (seat == 0) { input.clear(); + if (station != null) { + endRemoteControl(passenger); + } } } @@ -1305,11 +1451,19 @@ protected void positionRider(Entity passenger, MoveFunction moveFunction) { cargo.setXRot(getXRot() + pitch); cargo.roll = roll(); cargo.setDeltaMovement(Vec3.ZERO); + } else if (def != null && passenger instanceof ContainerEntity container) { + RackDefinition rack = def.rack(seatOf(passenger)); + container.setYRot(getYRot() + (rack != null ? rack.yaw() : 0.0F)); + container.setDeltaMovement(Vec3.ZERO); } } @Override public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { + Vec3 home = remoteReturns.remove(passenger.getId()); + if (home != null) { + return home; + } AircraftDefinition def = definition(); Integer seat = lastSeats.get(passenger.getId()); if (def != null) { @@ -1333,6 +1487,9 @@ public Vec3 getDismountLocationForPassenger(LivingEntity passenger) { @Override public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { ItemStack held = player.getItemInHand(hand); + if (held.getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } if (definition() != null && (held.getItem() instanceof FuelItem || held.getItem() instanceof WrenchItem)) { if (level().isClientSide()) { return InteractionResult.SUCCESS; @@ -1372,6 +1529,7 @@ public InteractionResult interact(Player player, InteractionHand hand, Vec3 loca } if (seatOf(player) == 0) { closeCanopy(); + boardNearbyMobs(); } } return InteractionResult.SUCCESS; @@ -1380,7 +1538,7 @@ public InteractionResult interact(Player player, InteractionHand hand, Vec3 loca // Racks /** Loads {@code cargo} into the given rack seat. Server only. */ - private boolean loadCargo(AircraftEntity cargo, int seat) { + private boolean loadCargo(Entity cargo, int seat) { if (!isSeatFree(seat) || cargo.isPassenger()) { return false; } @@ -1418,13 +1576,21 @@ public boolean acceptsCargo(AircraftEntity cargo, int seat) { return false; } + /** Whether rack {@code seat} carries containers, {@code AddRack} naming {@code container}. */ + public boolean acceptsContainer(ContainerEntity container, int seat) { + AircraftDefinition def = definition(); + RackDefinition rack = def != null ? def.rack(seat) : null; + return rack != null && !isPassenger() && !container.isPassenger() + && rack.accepts(ContainerEntity.CARGO_NAME, ContainerEntity.CARGO_NAME); + } + private boolean isNearRackEntry(AircraftEntity cargo, RackDefinition rack) { Vec3 entry = toWorld(rack.entry()); float r = rack.range(); return Math.abs(cargo.getX() - entry.x) <= r && Math.abs(cargo.getY() - entry.y) <= r && Math.abs(cargo.getZ() - entry.z) <= r; } - /** Carrier pilot loads every nearby vehicle that fits a free rack. Server only. */ + /** Carrier pilot loads every nearby vehicle or container that fits a free rack. Server only. */ public void loadNearbyCargo() { AircraftDefinition def = definition(); if (def == null || def.racks.isEmpty() || throttle > 0.3 || !hasBlockBelow(1, 3, true)) { @@ -1437,11 +1603,20 @@ public void loadNearbyCargo() { RackDefinition rack = def.rack(seat); Vec3 entry = toWorld(rack.entry()); AABB area = new AABB(entry, entry).inflate(rack.range()); + boolean loaded = false; for (AircraftEntity cargo : level().getEntitiesOfClass(AircraftEntity.class, area)) { if (acceptsCargo(cargo, seat) && loadCargo(cargo, seat)) { + loaded = true; break; } } + if (!loaded) { + for (ContainerEntity container : level().getEntitiesOfClass(ContainerEntity.class, area)) { + if (acceptsContainer(container, seat) && loadCargo(container, seat)) { + break; + } + } + } } } @@ -1465,6 +1640,10 @@ public void unloadCargo() { Vec3 at = toWorld(drop); cargo.stopRiding(); cargo.snapTo(at.x, at.y, at.z, getYRot() + rack.yaw(), 0.0F); + // As in the original, with no ground in the 20 blocks below it goes down under a canopy. + if (!(cargo instanceof AircraftEntity) && !hasBlockBelow(3, 20, false)) { + ParachuteEntity.open(level(), cargo, ParachuteEntity.Kind.DROP); + } return; } } @@ -1563,6 +1742,257 @@ public void eject(Entity entity) { ModSounds.playAt(entity, "eject_seat", 5.0F, 1.0F); } + // Search lights + + public boolean isSearchLightOn() { + AircraftDefinition def = definition(); + return def != null && !def.searchLights.isEmpty() && entityData.get(DATA_SEARCH_LIGHT); + } + + /** {@code canSwitchSearchLight}: the pilot or the first crew seat, in vehicles that have one. */ + public boolean canSwitchSearchLight(Entity entity) { + AircraftDefinition def = definition(); + int seat = seatOf(entity); + return def != null && !def.searchLights.isEmpty() && seat >= 0 && seat <= 1; + } + + /** Server only. */ + public void toggleSearchLight(Entity entity) { + if (canSwitchSearchLight(entity)) { + entityData.set(DATA_SEARCH_LIGHT, !entityData.get(DATA_SEARCH_LIGHT)); + } + } + + /** + * Where a turning search light points: the first crew seat's view, else the pilot's, as + * {@code renderLight} took them. Null with nobody aboard to aim it. + */ + public @Nullable Entity searchLightAimer() { + Entity crew = occupant(1); + return crew != null ? crew : occupant(0); + } + + // Troops: parachute drops and ropes + + public boolean isRepelling() { + return entityData.get(DATA_REPELLING); + } + + /** How far the ropes hang below their hooks, as a negative number. */ + public float ropeLength() { + return ropeLength; + } + + /** Where rope {@code index} hangs from, in the world. */ + public Vec3 ropePosition(int index) { + AircraftDefinition def = definition(); + return def != null && index >= 0 && index < def.repellingHooks.size() ? toWorld(def.repellingHooks.get(index).position()) : position(); + } + + /** + * {@code onUpdate_Repelling} and {@code onUpdate_UnmountCrew}: the ropes run out towards the + * ground, then the mobs aboard slide down them one per hook interval; a parachute drop lets one + * passenger out per {@code MobDropOption} interval while the hatch is open. + */ + private void tickRopes(AircraftDefinition def) { + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + int clear = airBelow(50); + if (ropeLength > -50.0F && ropeLength > -clear) { + ropeLength -= level().isClientSide() ? 0.3F : 0.25F; + } + } else { + ropeLength = 0.0F; + } + } + if (level().isClientSide()) { + return; + } + if (isRepelling() && isDestroyed()) { + entityData.set(DATA_REPELLING, false); + } + if (!isRepelling()) { + repellingTicks = 0; + } else if (repellingTicks < 60) { + repellingTicks++; + } else { + for (int rope = 0; rope < def.repellingHooks.size(); rope++) { + if (ticksAlive % def.repellingHooks.get(rope).interval() != 0) { + continue; + } + for (int seat = 1; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + rappel(mob, rope); + break; + } + } + } + } + if (droppingTroops) { + if (hasBlockBelow(3, 10, false) || isDestroyed()) { + droppingTroops = false; + } else if ((hatch == null || hatch.isOn()) && ticksAlive % def.dropInterval == 0 && !dropNextTroop(def)) { + droppingTroops = false; + } + } + } + + /** Air blocks straight below, up to {@code max}. */ + private int airBelow(int max) { + BlockPos.MutableBlockPos pos = blockPosition().mutable(); + int i = 0; + while (i < max && pos.getY() - i > level().getMinY() && level().getBlockState(pos.setY(blockPosition().getY() - i - 1)).isAir()) { + i++; + } + return i; + } + + /** + * {@code canStartRepelling}: a helicopter in hover mode, level and holding still. The original + * compared against where it was a few ticks before. + */ + private boolean canStartRepelling() { + return this instanceof HelicopterEntity && isHoveringMode() && Math.abs(getXRot()) < 3.0F && Math.abs(roll()) < 3.0F + && lastTickPos != null && position().distanceTo(lastTickPos) < 0.05; + } + + /** + * The pilot's drop key, {@code unmountCrew}: with ropes, lets them down from a steady hover + * or pulls them up again; with {@code EnableParachuting}, starts or stops dropping the mobs + * aboard under parachutes; otherwise, or close to the ground, simply lets the mobs out. + * Server only. + * + * @return false if nothing could be done, so the pilot hears it + */ + public boolean dropCrew() { + AircraftDefinition def = definition(); + if (def == null || isDestroyed()) { + return false; + } + if (!def.repellingHooks.isEmpty()) { + if (isRepelling()) { + entityData.set(DATA_REPELLING, false); + return true; + } + if (hasBlockBelow(3, 4, false)) { + return unloadMobs(); + } + if (!canStartRepelling()) { + return false; + } + entityData.set(DATA_REPELLING, true); + repellingTicks = 0; + return true; + } + if (droppingTroops) { + droppingTroops = false; + return true; + } + if (def.parachuting && !hasBlockBelow(3, 10, false)) { + droppingTroops = true; + if (hatch != null && hatch.isOff()) { + hatch.set(true, true); + modeSwitchCooldown = 20; + } + return true; + } + return unloadMobs(); + } + + /** Lets every mob in a crew seat out, as in {@code unmountCrew(false)}. */ + private boolean unloadMobs() { + AircraftDefinition def = definition(); + boolean any = false; + for (int seat = 1; def != null && seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + mob.stopRiding(); + any = true; + } + } + return any; + } + + /** The next mob behind the second seat leaves at the drop point under a parachute. */ + private boolean dropNextTroop(AircraftDefinition def) { + for (int seat = 2; seat < def.seatCount(); seat++) { + if (occupant(seat) instanceof Mob mob) { + parachuteOut(mob, def); + return true; + } + } + return false; + } + + private void parachuteOut(Entity entity, AircraftDefinition def) { + Vec3 out = toWorld(def.dropPosition); + entity.stopRiding(); + entity.teleportTo(out.x, out.y, out.z); + entity.setDeltaMovement(getDeltaMovement()); + entity.needsSync = true; + ParachuteEntity.open(level(), entity, ParachuteEntity.Kind.DROP); + } + + /** + * A passenger's own drop key, {@code MCH_ClientSeatTickHandler}: slide down a rope once the + * ropes are out, or jump with a parachute from behind the second seat of a vehicle with + * {@code EnableParachuting} and room below. Server only. + * + * @return false if neither is possible right now + */ + public boolean jumpOut(Player player) { + AircraftDefinition def = definition(); + int seat = seatOf(player); + if (def == null || seat <= 0 || seat >= def.seatCount() || isDestroyed()) { + return false; + } + if (isRepelling() && repellingTicks > 50 && !def.repellingHooks.isEmpty()) { + lastRope = (lastRope + 1) % def.repellingHooks.size(); + rappel(player, lastRope); + return true; + } + if (def.parachuting && seat >= 2 && !hasBlockBelow(3, 13, false)) { + parachuteOut(player, def); + return true; + } + return false; + } + + /** {@code unmountEntityRepelling}: two blocks under the hook, onto a rope rider that lets them down. */ + private void rappel(Entity entity, int rope) { + Vec3 at = ropePosition(rope).add(0.0, -2.0, 0.0); + entity.stopRiding(); + entity.teleportTo(at.x, at.y, at.z); + entity.setDeltaMovement(Vec3.ZERO); + entity.resetFallDistance(); + RappelEntity rider = new RappelEntity(ModEntities.RAPPEL.get(), level()); + rider.attach(this, rope); + rider.setPos(at); + level().addFreshEntity(rider); + entity.startRiding(rider, true, true); + lastRope = rope; + } + + /** + * {@code mountMobToSeats}: when the pilot climbs in, mobs standing close by take the free crew + * seats, so villagers and the like can be flown somewhere. Server only. + */ + private void boardNearbyMobs() { + AircraftDefinition def = definition(); + if (def == null || def.uav) { + return; + } + for (Mob mob : level().getEntitiesOfClass(Mob.class, getBoundingBox().inflate(3.0, 2.0, 3.0), m -> !m.isPassenger() && !m.isVehicle())) { + for (int seat = 1; seat < def.seatCount(); seat++) { + if (isSeatFree(seat)) { + pendingSeat = seat; + mob.startRiding(this, true, true); + pendingSeat = EMPTY; + break; + } + } + } + } + // Terrain helpers /** True if any solid block is within {@code depth} blocks below the vehicle. */ @@ -2348,10 +2778,15 @@ protected void addAdditionalSaveData(ValueOutput output) { output.putInt("DespawnCount", despawnCount); output.putInt("CookOff", cookOffBlasts); output.putBoolean("Sinking", entityData.get(DATA_SINKING)); + output.putBoolean("SearchLight", entityData.get(DATA_SEARCH_LIGHT)); if (weapons != null) { output.putIntArray("WeaponsAmmo", weapons.saveAmmo()); } inventory.save(output); + if (station != null && stationReturn != null) { + output.store("Station", UUIDUtil.CODEC, station); + output.store("StationReturn", Vec3.CODEC, stationReturn); + } ValueOutput.TypedOutputList seats = output.list("Seats", Codec.STRING); for (int i = 0; i < seatIds.length; i++) { Entity e = occupant(i); @@ -2378,6 +2813,7 @@ protected void readAdditionalSaveData(ValueInput input) { cookOffBlasts = input.getIntOr("CookOff", 0); cookOffDelay = cookOffBlasts > 0 ? 20 : 0; entityData.set(DATA_SINKING, input.getBooleanOr("Sinking", false)); + entityData.set(DATA_SEARCH_LIGHT, input.getBooleanOr("SearchLight", false)); exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); input.getIntArray("WeaponsAmmo").ifPresent(ammo -> { if (weapons != null) { @@ -2387,6 +2823,8 @@ protected void readAdditionalSaveData(ValueInput input) { } }); inventory.load(input); + station = input.read("Station", UUIDUtil.CODEC).orElse(null); + stationReturn = input.read("StationReturn", Vec3.CODEC).orElse(null); savedSeats.clear(); List seats = input.listOrEmpty("Seats", Codec.STRING).stream().toList(); for (int i = 0; i < seats.size(); i++) { diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java new file mode 100644 index 0000000..4aaab44 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ChainEntity.java @@ -0,0 +1,193 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * A tow chain, {@code MCH_EntityChain}: it hangs above the vehicle that pulls and drags the towed + * one along, letting it drift up to the chain's length away and never more than two blocks past + * that. It goes when either end does. + */ +public class ChainEntity extends Entity { + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 15; + /** Ticks a chain loaded from disk waits for both ends to load before it gives up. */ + private static final int SEARCH_TICKS = 50; + + private static final EntityDataAccessor DATA_TOW = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_TOWED = SynchedEntityData.defineId(ChainEntity.class, EntityDataSerializers.INT); + + private @Nullable UUID towId; + private @Nullable UUID towedId; + private @Nullable Entity tow; + private @Nullable Entity towed; + private int length = 4; + private int searchTicks = SEARCH_TICKS; + + public ChainEntity(EntityType type, Level level) { + super(type, level); + noPhysics = true; + } + + /** Chains {@code towed} to {@code tow}, at the distance they are apart now. Server only. */ + public static ChainEntity connect(Level level, Entity towed, Entity tow) { + ChainEntity chain = new ChainEntity(ModEntities.CHAIN.get(), level); + chain.setPos(tow.position().add(towed.position()).scale(0.5)); + chain.length = Mth.clamp((int) towed.distanceTo(tow), MIN_LENGTH, MAX_LENGTH); + chain.attach(towed, tow); + level.addFreshEntity(chain); + ModSounds.playAt(chain.tow, "chain_ct", 1.0F, 1.0F); + return chain; + } + + /** Whether {@code entity} can pull the other end: only vehicles can. */ + public static boolean canTow(Entity entity) { + return entity instanceof AircraftEntity; + } + + /** + * Only a vehicle pulls. Hooked the other way round, a container would hold a helicopter down, + * so the ends swap whichever order they were clicked in (and chains saved that way are fixed + * when they load). + */ + private void attach(Entity towed, Entity tow) { + if (!canTow(tow) && canTow(towed)) { + Entity swap = tow; + tow = towed; + towed = swap; + } + this.towed = towed; + this.tow = tow; + this.towedId = towed.getUUID(); + this.towId = tow.getUUID(); + entityData.set(DATA_TOWED, towed.getId()); + entityData.set(DATA_TOW, tow.getId()); + searchTicks = 0; + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_TOW, -1); + builder.define(DATA_TOWED, -1); + } + + /** The end that pulls; on a client, as far as it has loaded. */ + public @Nullable Entity tow() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOW)) : tow; + } + + /** The end that is pulled. */ + public @Nullable Entity towed() { + return level().isClientSide() ? level().getEntity(entityData.get(DATA_TOWED)) : towed; + } + + /** Whether {@code entity} is either end of this chain. */ + public boolean holds(Entity entity) { + return entity == tow() || entity == towed(); + } + + /** Takes the chain off, with the sound of it coming loose. Server only. */ + public void disconnect() { + ModSounds.playAt(this, "chain_ct", 1.0F, 1.0F); + discard(); + } + + /** The chain on {@code entity}, at either end, if there is one within reach. */ + public static @Nullable ChainEntity on(Entity entity) { + for (ChainEntity chain : entity.level().getEntitiesOfClass(ChainEntity.class, entity.getBoundingBox().inflate(25.0))) { + if (chain.holds(entity)) { + return chain; + } + } + return null; + } + + @Override + public void tick() { + super.tick(); + if (!(level() instanceof ServerLevel server)) { + return; + } + if (searchTicks > 0) { + searchTicks--; + Entity foundTowed = towedId != null ? server.getEntity(towedId) : null; + Entity foundTow = towId != null ? server.getEntity(towId) : null; + if (foundTowed != null && foundTow != null) { + attach(foundTowed, foundTow); + } + return; + } + if (tow == null || towed == null || tow.isRemoved() || towed.isRemoved() || tow == towed) { + disconnect(); + return; + } + towed.resetFallDistance(); + setPos(tow.getX(), tow.getY() + 2.0, tow.getZ()); + pull(tow, towed); + } + + /** {@code updateTowingEntityPosRot}: turns the towed end to face along the chain and drags it in. */ + private void pull(Entity tow, Entity towed) { + Vec3 d = towed.position().subtract(tow.position()); + double dist = d.length(); + if (dist <= length) { + return; + } + towed.setYRot((float) Math.toDegrees(Math.atan2(d.z, d.x)) + 90.0F); + double slack = dist - length; + towed.setDeltaMovement(towed.getDeltaMovement().subtract(d.scale(0.001 / slack))); + double max = length + 2.0; + if (dist > max) { + Vec3 at = tow.position().add(d.scale(max / dist)); + towed.setPos(at.x, at.y, at.z); + } + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + towId = input.read("Tow", UUIDUtil.CODEC).orElse(null); + towedId = input.read("Towed", UUIDUtil.CODEC).orElse(null); + length = Mth.clamp(input.getIntOr("Length", 4), MIN_LENGTH, MAX_LENGTH); + searchTicks = SEARCH_TICKS; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + if (towId != null && towedId != null) { + output.store("Tow", UUIDUtil.CODEC, towId); + output.store("Towed", UUIDUtil.CODEC, towedId); + } + output.putInt("Length", length); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java new file mode 100644 index 0000000..82843f9 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/ContainerEntity.java @@ -0,0 +1,151 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.ChainItem; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.network.chat.Component; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.LinearInterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A cargo container, {@code MCH_EntityContainer}: a crate with a large chest's worth of room that + * sits on the ground, floats, can be pushed around, and rides the racks of the vehicles that carry + * {@code container}. Dropped from a rack high enough it comes down under a parachute. + */ +public class ContainerEntity extends Entity { + /** What racks call it in their {@code AddRack} lines. */ + public static final String CARGO_NAME = "container"; + private static final int SLOTS = 54; + /** Punches add up; the original broke it past 40 with 20 per point of damage. */ + private static final int BREAK_DAMAGE = 40; + + private final SimpleContainer items = new SimpleContainer(SLOTS); + private int damageTaken; + + public ContainerEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + @Override + public void tick() { + super.tick(); + if (level().isClientSide() || isPassenger()) { + return; + } + if (damageTaken > 0) { + damageTaken--; + } + Vec3 m = getDeltaMovement(); + double my = m.y; + if (isInWater() || isInLava()) { + // Bobs up to the surface rather than sinking, as the original's boat physics did. + my = (my < 0.0 ? my / 2.0 : my) + (isEyeInFluid(FluidTags.WATER) ? 0.04 : 0.007); + } else { + my -= 0.04; + } + double mx = m.x; + double mz = m.z; + if (onGround()) { + mx *= 0.9; + mz *= 0.9; + } + move(MoverType.SELF, new Vec3(mx, my, mz)); + setDeltaMovement(mx * 0.99, my * 0.95, mz * 0.99); + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (player.getItemInHand(hand).getItem() instanceof ChainItem) { + return ChainItem.useOn(this, player); + } + if (player.isSecondaryUseActive()) { + return InteractionResult.PASS; + } + if (player instanceof ServerPlayer serverPlayer) { + serverPlayer.openMenu(new SimpleMenuProvider((id, inventory, p) -> ChestMenu.sixRows(id, inventory, items), + Component.translatable("entity.mcheli.container"))); + } + return InteractionResult.SUCCESS; + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved() || !(source.getEntity() instanceof Player player)) { + // Only players take it apart; explosions and stray rounds leave the cargo alone. + return false; + } + ModSounds.playAt(this, "hit", 1.0F, 1.3F); + damageTaken += (int) (damage * 20.0F); + boolean creative = player.getAbilities().instabuild; + if (creative || damageTaken > BREAK_DAMAGE) { + if (!creative) { + spawnAtLocation(level, ModItems.CONTAINER.get()); + } + Containers.dropContents(level, this, items); + discard(); + } + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public boolean isPushable() { + return !isPassenger(); + } + + @Override + public boolean canBeCollidedWith(@Nullable Entity other) { + return !isPassenger(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.CONTAINER.get()); + } + + @Override + protected InterpolationHandler createInterpolationHandler() { + return LinearInterpolationHandler.create(this, 3); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + items.clearContent(); + ContainerHelper.loadAllItems(input, items.getItems()); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + ContainerHelper.saveAllItems(output, items.getItems(), false); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java new file mode 100644 index 0000000..a7ec17f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/GltdEntity.java @@ -0,0 +1,212 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.weapon.CasStrike; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.LinearInterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * The ground laser target designator, {@code MCH_EntityGLTD}: a tripod a player sits behind. Its + * sight looks up to 70 degrees either side of where it was set up, zooms and has night vision, + * and marking a spot calls in an A-10 for three gun runs over it. + */ +public class GltdEntity extends Entity { + /** How far the sight turns from the tripod's heading, either way, and up or down. */ + public static final float VIEW_LIMIT = 70.0F; + /** The CAS weapon the A-10s fire; the original hard coded it too. */ + private static final String WEAPON = "a10gau8"; + /** Added to the weapon's own {@code Delay} between two calls. */ + private static final int EXTRA_WAIT = 150; + private static final double SIGHT_RANGE = 300.0; + + private static final EntityDataAccessor DATA_WAIT = SynchedEntityData.defineId(GltdEntity.class, EntityDataSerializers.INT); + + private final CasStrike strike = new CasStrike(WEAPON); + private boolean nightVision; + + public GltdEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_WAIT, 0); + } + + /** Ticks until the next A-10 can be called; the sight shows it. */ + public int waitTicks() { + return entityData.get(DATA_WAIT); + } + + /** The eye of the sight, a little in front of and above the tripod. */ + public Vec3 sightPosition(float partialTicks) { + float yaw = getYRot(partialTicks) * Mth.DEG_TO_RAD; + Vec3 pos = getPosition(partialTicks); + return new Vec3(pos.x - Mth.sin(yaw) * 0.6, pos.y + 0.7, pos.z + Mth.cos(yaw) * 0.6); + } + + @Override + public void tick() { + super.tick(); + if (level().isClientSide()) { + return; + } + Vec3 m = getDeltaMovement().add(0.0, -0.04, 0.0); + if (onGround()) { + m = m.scale(0.5); + } + move(MoverType.SELF, m); + setDeltaMovement(m.x * 0.99, m.y * 0.95, m.z * 0.99); + + if (waitTicks() > 0) { + entityData.set(DATA_WAIT, waitTicks() - 1); + } + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (def != null && level() instanceof ServerLevel server) { + strike.tick(server, def); + } + Entity viewer = getFirstPassenger(); + if (viewer == null) { + nightVision = false; + } else if (nightVision) { + NightVision.topUp(viewer); + } + } + + /** + * {@code useCurrentWeapon}: marks the block {@code player} looks at through the sight and calls + * the A-10 on it. Server only. + * + * @return whether the strike was called; the sight may be waiting, or see nothing in range + */ + public boolean callStrike(ServerPlayer player) { + WeaponDefinition def = MCHeli.content().weapon(WEAPON); + if (getFirstPassenger() != player || waitTicks() > 0 || def == null || !(level() instanceof ServerLevel server)) { + return false; + } + Vec3 from = sightPosition(1.0F); + Vec3 to = from.add(player.getLookAngle().scale(SIGHT_RANGE)); + BlockHitResult hit = server.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + strike.start(hit.getLocation(), player.getYRot(), this, player); + entityData.set(DATA_WAIT, def.delay + EXTRA_WAIT); + ModSounds.playAt(server, getX(), getY(), getZ(), "gltd", 0.5F, 1.0F); + if (def.sound != null) { + ModSounds.playTo(player, def.sound, 1.0F, 1.0F); + } + return true; + } + + public void setNightVision(Player player, boolean on) { + if (getFirstPassenger() != player) { + return; + } + nightVision = on; + if (!on) { + NightVision.end(player); + } + } + + @Override + public InteractionResult interact(Player player, InteractionHand hand, Vec3 location) { + if (player.isSecondaryUseActive() || isVehicle()) { + return InteractionResult.PASS; + } + if (!level().isClientSide()) { + player.setYRot(getYRot()); + player.setXRot(0.0F); + player.startRiding(this); + } + return InteractionResult.SUCCESS; + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + if (!level().isClientSide() && nightVision) { + NightVision.end(passenger); + nightVision = false; + } + } + + /** The viewer sits behind the tripod. */ + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, -0.5).yRot(-getYRot() * Mth.DEG_TO_RAD); + } + + /** The original clamped the viewer's head the same way every tick. */ + @Override + public void onPassengerTurned(Entity passenger) { + float yaw = getYRot() + Mth.clamp(Mth.wrapDegrees(passenger.getYRot() - getYRot()), -VIEW_LIMIT, VIEW_LIMIT); + passenger.setYRot(yaw); + passenger.setYHeadRot(yaw); + passenger.setXRot(Mth.clamp(passenger.getXRot(), -VIEW_LIMIT, VIEW_LIMIT)); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (isInvulnerableToBase(source) || isRemoved()) { + return false; + } + boolean creative = source.getEntity() instanceof Player player && player.getAbilities().instabuild; + ejectPassengers(); + if (!creative) { + spawnAtLocation(level, ModItems.GLTD.get()); + } + ModSounds.playAt(level, getX(), getY(), getZ(), "hit", 1.0F, 1.0F); + discard(); + return true; + } + + @Override + public boolean isPickable() { + return !isRemoved(); + } + + @Override + public @Nullable ItemStack getPickResult() { + return new ItemStack(ModItems.GLTD.get()); + } + + @Override + protected InterpolationHandler createInterpolationHandler() { + return LinearInterpolationHandler.create(this, 3); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java index 4d3143b..957bf9e 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -2,11 +2,17 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; import com.jnhprojects.mcheli.physics.AircraftRotation; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; import net.minecraft.util.Mth; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MoverType; import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; @@ -21,7 +27,8 @@ *

*/ public class HelicopterEntity extends AircraftEntity implements EngineSoundSource { @@ -37,9 +44,25 @@ public class HelicopterEntity extends AircraftEntity implements EngineSoundSourc private static final float ROLL_RATE = 1.8F; private static final double MIN_SPEED = 0.07; + private static final EntityDataAccessor DATA_FOLD = SynchedEntityData.defineId(HelicopterEntity.class, EntityDataSerializers.BYTE); + // DATA_FOLD values, as the original numbered them. + private static final byte FOLDED = 0; + private static final byte FOLDING = 1; + private static final byte UNFOLDED = 2; + private static final byte UNFOLDING = 3; + /** {@code MCH_Blade}: degrees a blade swings per tick while folding. */ + private static final float FOLD_SPEED = 2.0F; + private static final int FOLD_COOLDOWN = 40; + private double currentSpeed = MIN_SPEED; private float rotorAngle; public float rotorAngleO; + /** Per rotor, per blade: where each blade points while they fold, and whether that rotor is folded. */ + private float[][] blades = new float[0][]; + private float[][] bladesO = new float[0][]; + private boolean[] rotorFolded = new boolean[0]; + private int foldCooldown; + private byte lastFold = -1; public HelicopterEntity(EntityType type, Level level) { super(type, level); @@ -92,11 +115,210 @@ public float rotorAngle(float partialTicks) { return prev + (rotorAngle - prev) * partialTicks; } + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(DATA_FOLD, UNFOLDED); + } + + // Blade folding + + private byte foldState() { + return entityData.get(DATA_FOLD); + } + + private boolean hasFoldingRotors() { + HelicopterDefinition def = definition(); + return def != null && def.foldableBlades && !def.rotors.isEmpty(); + } + + /** {@code isFoldBlades}: folded, or on the way. */ + public boolean isFoldedBlades() { + return hasFoldingRotors() && foldState() == FOLDED; + } + + /** {@code canUseBlades}: the blades are out and none is still swinging into place. */ + public boolean canUseBlades() { + if (foldState() != UNFOLDED) { + return false; + } + for (int r = 0; r < rotorFolded.length; r++) { + if (rotorFolded[r]) { + return false; + } + } + return true; + } + + /** {@code canSwitchFoldBlades}: with the engine stopped and the last switch finished. */ + private boolean canSwitchFoldBlades() { + byte state = foldState(); + return hasFoldingRotors() && visibleThrottle() <= 0.01F && foldCooldown == 0 && (state == UNFOLDED || state == FOLDED); + } + + /** The fold key folds the blades of helicopters that can, and works the hatches otherwise. */ + @Override + public void toggleFold() { + if (canSwitchFoldBlades()) { + entityData.set(DATA_FOLD, foldState() == FOLDED ? UNFOLDING : FOLDING); + foldCooldown = FOLD_COOLDOWN; + return; + } + super.toggleFold(); + } + + /** + * {@code onUpdate_Rotor} with {@code MCH_Rotor.update} and {@code MCH_Blade}: spinning blades + * follow the rotor; folding ones swing one by one onto the tail, each a few degrees further + * than the one before, and swing out again until they meet the spinning rotor's angle. + */ + private void tickBlades(HelicopterDefinition def) { + int rotors = def.rotors.size(); + if (blades.length != rotors) { + blades = new float[rotors][]; + bladesO = new float[rotors][]; + rotorFolded = new boolean[rotors]; + for (int r = 0; r < rotors; r++) { + int count = Math.max(def.rotors.get(r).blades(), 1); + blades[r] = new float[count]; + bladesO[r] = new float[count]; + } + if (foldState() == FOLDED || foldState() == FOLDING) { + forceFold(def); + } + } + byte state = foldState(); + if (state != lastFold) { + lastFold = state; + foldCooldown = FOLD_COOLDOWN; + } else if (foldCooldown > 0) { + foldCooldown--; + } + boolean target = state == FOLDING || state == FOLDED; + boolean settled = true; + for (int r = 0; r < rotors; r++) { + RotorDefinition rotor = def.rotors.get(r); + boolean wanted = target && rotor.foldable(); + boolean done = true; + for (int b = 0; b < blades[r].length; b++) { + float base = b * Math.max(rotor.bladeStep(), 1); + float fold = 5.0F + b * 3.0F; + bladesO[r][b] = blades[r][b]; + if (!rotorFolded[r]) { + if (!wanted) { + blades[r][b] = wrap(rotorAngle + base); + done = false; + } else if (!foldBlade(r, b, fold)) { + done = false; + } + } else if (wanted) { + done = false; + } else if (!unfoldBlade(r, b, base)) { + done = false; + } + } + if (done) { + rotorFolded[r] = wanted; + } + if (rotorFolded[r] != wanted) { + settled = false; + } + } + if (settled && !level().isClientSide()) { + if (state == FOLDING) { + entityData.set(DATA_FOLD, FOLDED); + } else if (state == UNFOLDING) { + entityData.set(DATA_FOLD, UNFOLDED); + } + } + } + + /** {@code MCH_Blade.folding}: towards the nearer side of the tail. */ + private boolean foldBlade(int r, int b, float fold) { + float angle = blades[r][b]; + if (angle > fold && angle < 360.0F - fold) { + blades[r][b] = wrap(angle < 180.0F ? angle - FOLD_SPEED : angle + FOLD_SPEED); + return false; + } + return true; + } + + /** {@code MCH_Blade.unfolding}: back round until the blade meets its place on the turning rotor. */ + private boolean unfoldBlade(int r, int b, float base) { + float target = wrap(rotorAngle + base); + float before = blades[r][b]; + if (target <= 180.0F) { + blades[r][b] = wrap(before + FOLD_SPEED); + if (blades[r][b] >= target && before <= target) { + blades[r][b] = target; + return true; + } + } else { + blades[r][b] = wrap(before - FOLD_SPEED); + if (blades[r][b] <= target && before >= target) { + blades[r][b] = target; + return true; + } + } + return false; + } + + /** {@code forceFold}: a helicopter loaded with its blades folded starts with them folded. */ + private void forceFold(HelicopterDefinition def) { + for (int r = 0; r < blades.length; r++) { + if (!def.rotors.get(r).foldable()) { + continue; + } + for (int b = 0; b < blades[r].length; b++) { + float fold = 5.0F + b * 3.0F; + float angle = wrap(rotorAngle + b * Math.max(def.rotors.get(r).bladeStep(), 1)); + if (angle > fold && angle < 360.0F - fold) { + angle = angle < 180.0F ? fold : 360.0F - fold; + } + blades[r][b] = angle; + bladesO[r][b] = angle; + } + rotorFolded[r] = true; + } + } + + private static float wrap(float degrees) { + float d = degrees % 360.0F; + return d < 0.0F ? d + 360.0F : d; + } + + /** Where blade {@code blade} of rotor {@code rotor} points, for drawing. */ + public float bladeAngle(int rotor, int blade, int step, float partialTicks) { + if (rotor >= blades.length || blade >= blades[rotor].length) { + return rotorAngle(partialTicks) + blade * step; + } + float prev = bladesO[rotor][blade]; + float now = blades[rotor][blade]; + if (now - prev < -180.0F) { + prev -= 360.0F; + } else if (prev - now < -180.0F) { + prev += 360.0F; + } + return prev + (now - prev) * partialTicks; + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putBoolean("FoldBlade", foldState() == FOLDED || foldState() == FOLDING); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + entityData.set(DATA_FOLD, input.getBooleanOr("FoldBlade", false) ? FOLDED : UNFOLDED); + } + /** Pilot toggles hover mode. Only allowed while fairly level and not in gunner mode, as in the original. */ public void toggleHovering() { if (isHoveringMode()) { setHovering(false); - } else if (!isGunnerMode() && isLevelEnough()) { + } else if (!isGunnerMode() && isLevelEnough() && canUseBlades()) { setHovering(true); } } @@ -105,10 +327,9 @@ private boolean isLevelEnough() { return Math.abs(Mth.wrapDegrees(roll())) < 40.0F && Math.abs(Mth.wrapDegrees(getXRot())) < 40.0F; } - /** The original also needed the blades unfolded; folding is not ported yet. */ @Override public boolean canSwitchGunnerMode() { - return super.canSwitchGunnerMode() && isLevelEnough(); + return super.canSwitchGunnerMode() && isLevelEnough() && canUseBlades(); } @Override @@ -166,6 +387,18 @@ protected void simulate() { private void updateThrottle(HelicopterDefinition def, boolean piloted) { if (piloted) { Vec3 motion = getDeltaMovement(); + if (!canUseBlades()) { + // Folded blades give no lift; on the ground A/D taxi the helicopter round slowly. + throttle = Math.max(0.0, throttle - 0.02); + if (onGround() && isFoldedBlades() && !isDestroyed()) { + if (input.left && !input.right) { + setYRot(getYRot() - 0.5F); + } else if (input.right && !input.left) { + setYRot(getYRot() + 0.5F); + } + } + return; + } if (!isHovering()) { if (input.throttleUp) { throttle = Math.min(1.0, throttle + 0.02 * def.throttleUpDown); @@ -315,5 +548,6 @@ protected void tickAnimation() { rotorAngleO = rotorAngle; float spool = 1.0F - visibleThrottle(); rotorAngle = (rotorAngle + (1.0F - spool * spool * spool) * def.rotorSpeed) % 360.0F; + tickBlades(def); } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java new file mode 100644 index 0000000..f52c422 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/NightVision.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * Night vision through a sight, {@code MCH_Camera.updateViewer}: a short effect kept topped up while + * the viewer has it switched on, and taken away again when they switch it off or leave. + */ +public final class NightVision { + private static final int TICKS = 300; + + private NightVision() { + } + + /** Called every tick for each viewer. It never runs below ten seconds, where vanilla starts to flicker the view. */ + public static void topUp(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current == null || current.getDuration() < TICKS - 40) { + living.addEffect(new MobEffectInstance(MobEffects.NIGHT_VISION, TICKS, 0, true, false, false)); + } + } + } + + /** Removes ours, leaving a longer one from a potion alone. */ + public static void end(Entity viewer) { + if (viewer instanceof LivingEntity living) { + MobEffectInstance current = living.getEffect(MobEffects.NIGHT_VISION); + if (current != null && current.getDuration() <= TICKS) { + living.removeEffect(MobEffects.NIGHT_VISION); + } + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java new file mode 100644 index 0000000..d960e9d --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/RappelEntity.java @@ -0,0 +1,105 @@ +package com.jnhprojects.mcheli.entity; + +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.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; + +/** + * Carries someone down a helicopter's rope, {@code MCH_EntityHide}. It stays under its hook while + * the helicopter drifts, slides down at a steady pace and lets its rider off at the ground or at + * the end of the rope. Pulling the ropes in drops whoever is still on them. + */ +public class RappelEntity extends Entity { + private static final EntityDataAccessor DATA_VEHICLE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + private static final EntityDataAccessor DATA_ROPE = SynchedEntityData.defineId(RappelEntity.class, EntityDataSerializers.INT); + + public RappelEntity(EntityType type, Level level) { + super(type, level); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_VEHICLE, 0); + builder.define(DATA_ROPE, -1); + } + + public void attach(AircraftEntity vehicle, int rope) { + entityData.set(DATA_VEHICLE, vehicle.getId()); + entityData.set(DATA_ROPE, rope); + } + + private @Nullable AircraftEntity vehicle() { + return level().getEntity(entityData.get(DATA_VEHICLE)) instanceof AircraftEntity a && a.isAlive() ? a : null; + } + + @Override + public void tick() { + super.tick(); + AircraftEntity vehicle = vehicle(); + Entity rider = getFirstPassenger(); + if (rider != null) { + rider.resetFallDistance(); + } + if (level().isClientSide()) { + return; + } + if (vehicle == null || !vehicle.isRepelling() || rider == null) { + arrive(); + return; + } + Vec3 hook = vehicle.ropePosition(entityData.get(DATA_ROPE)); + Vec3 m = getDeltaMovement(); + double fall = (m.y - (onGround() ? 0.01 : 0.03)) * 0.9; + setPos(hook.x, getY(), hook.z); + if (onGround()) { + arrive(); + return; + } + move(MoverType.SELF, new Vec3(0.0, fall, 0.0)); + setDeltaMovement(0.0, fall, 0.0); + if (Math.abs(getY() - hook.y) > Math.abs(vehicle.ropeLength()) + 5.0F) { + arrive(); + } + } + + private void arrive() { + ejectPassengers(); + discard(); + } + + @Override + protected Vec3 getPassengerAttachmentPoint(Entity passenger, EntityDimensions dimensions, float scale) { + return new Vec3(0.0, -0.3, 0.0); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + protected InterpolationHandler createInterpolationHandler() { + return LinearInterpolationHandler.create(this, 3); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java new file mode 100644 index 0000000..ca0b012 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/UavStationEntity.java @@ -0,0 +1,338 @@ +package com.jnhprojects.mcheli.entity; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.menu.UavStationMenu; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.chat.Component; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.Containers; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.LinearInterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * The UAV station and the portable UAV controller, {@code MCH_EntityUavStation}. A player sits at + * it and puts a UAV in its slot: the drone appears at the station's launch offset and the player + * flies it from there. The portable controller only launches small UAVs and its lid opens and + * closes with Shift and right-click. + * + *

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

    + *
  • {@code onUpdate_ParticleSandCloud}: a helicopter's downwash raises a ring of dust off the + * ground or water below it; boats and amphibians with {@code EnableSeaSurfaceParticle} raise + * one off the sea while under way.
  • + *
  • {@code onUpdate_ParticleSplash}: each {@code AddParticleSplash} point sprays while it moves + * through water.
  • + *
  • {@code onUpdate_ParticleNozzle}: jet nozzles smoke with the throttle, more when the blast + * hits the ground, as on a Harrier in the hover.
  • + *
  • {@code onUpdate_ParticleLandingGear}: a plane rolling fast kicks up bits of the ground.
  • + *
+ */ +final class VehicleParticles { + private static final int WHITE = 0xFFFFFF; + + private VehicleParticles() { + } + + static void tick(AircraftEntity vehicle, AircraftDefinition def) { + if (vehicle.isDestroyed()) { + return; + } + switch (vehicle) { + case HelicopterEntity heli -> sandCloud(heli, def, false); + case PlaneEntity plane -> { + if (def instanceof PlaneDefinition planeDef) { + nozzles(plane, planeDef); + } + landingGear(plane); + splashes(plane, def); + sandCloud(plane, def, true); + } + case TankEntity tank -> { + splashes(tank, def); + sandCloud(tank, def, true); + } + default -> { + } + } + } + + private static void sandCloud(AircraftEntity vehicle, AircraftDefinition def, boolean seaOnly) { + if (seaOnly && !def.seaSurfaceParticles || def.particlesScale <= 0.01F) { + return; + } + Level level = vehicle.level(); + RandomSource random = vehicle.getRandom(); + float scale = def.particlesScale * 3.0F * (seaOnly ? 2.0F : 1.0F); + double throttle = Math.min(vehicle.visibleThrottle() * 2.0, 1.0); + // A boat needs some water under it before it sprays; the original counted water blocks. + int count = seaOnly ? (int) (scale * 7.0F) : 0; + int range = (int) (scale * 10.0F) + 1; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int bx = Mth.floor(vehicle.getX() + 0.5); + int by = Mth.floor(vehicle.getY() + 0.5); + int bz = Mth.floor(vehicle.getZ() + 0.5); + int y = 0; + double groundY = 0.0; + boolean found = false; + for (; y < range && !found; y++) { + for (int x = -1; x <= 1 && !found; x++) { + for (int z = -1; z <= 1 && !found; z++) { + BlockState state = level.getBlockState(pos.set(bx + x, by - y, bz + z)); + if (state.isAir()) { + continue; + } + if (seaOnly && state.getFluidState().is(FluidTags.WATER)) { + count--; + } + if (count <= 0) { + groundY = vehicle.getY() + 1.0 + scale / 5.0F - y; + found = true; + } + } + } + } + if (!found) { + return; + } + double density = (range - y + 1) / (5.0 * scale) / 2.0; + for (int k = 0; k < (int) (throttle * 6.0 * density); k++) { + float angle = random.nextFloat() * Mth.TWO_PI; + double dx = Mth.cos(angle); + double dz = Mth.sin(angle); + level.addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, scale * 5.0F, (int) (10.0F * scale) + 1), true, + vehicle.getX() + dx * scale * 3.0, groundY + (random.nextDouble() - 0.5) * scale, vehicle.getZ() + dz * scale * 3.0, + scale * dx * 0.3, scale * -0.02, scale * dz * 0.3); + } + } + + private static void splashes(AircraftEntity vehicle, AircraftDefinition def) { + if (def.splashes.isEmpty()) { + return; + } + double mx = vehicle.getX() - vehicle.xo; + double mz = vehicle.getZ() - vehicle.zo; + double dist = Math.min(mx * mx + mz * mz, 1.0); + RandomSource random = vehicle.getRandom(); + for (ParticleSplash splash : def.splashes) { + for (int i = 0; i < splash.count(); i++) { + if (dist > 0.03 + random.nextFloat() * 0.1) { + splash(vehicle, splash, -mx * splash.acceleration(), -mz * splash.acceleration(), splash.size() * (0.5 + dist * 0.5)); + } + } + } + } + + private static void splash(AircraftEntity vehicle, ParticleSplash splash, double mx, double mz, double size) { + RandomSource random = vehicle.getRandom(); + Vec3 at = vehicle.toWorld(splash.position()).add(random.nextDouble() - 0.5, (random.nextDouble() - 0.5) * 0.5, random.nextDouble() - 0.5); + BlockPos block = BlockPos.containing(at.x + 0.5, at.y, at.z + 0.5); + if (!vehicle.level().getFluidState(block).is(FluidTags.WATER)) { + return; + } + int age = splash.age() + (int) (random.nextFloat() * 0.5F * splash.age()); + vehicle.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, (float) size * (random.nextFloat() * 0.2F + 0.8F), age), true, + at.x, at.y, at.z, mx + (random.nextFloat() - 0.5) * 0.7, splash.motionY(), mz + (random.nextFloat() - 0.5) * 0.7); + } + + private static void nozzles(PlaneEntity plane, PlaneDefinition def) { + float throttle = plane.visibleThrottle(); + if (def.nozzles.isEmpty() || throttle <= 0.1F) { + return; + } + RandomSource random = plane.getRandom(); + PartState nozzle = plane.nozzle(); + float tilt = nozzle != null ? nozzle.angle() * Mth.DEG_TO_RAD : 0.0F; + // Out of the back, turned down as the nozzles swing towards the hover. + Vector3f blast = plane.toWorldOffset(new Vector3(0.0, -Mth.sin(tilt), -Mth.cos(tilt))); + for (MovingPart part : def.nozzles) { + if (random.nextFloat() > throttle * 1.5F) { + continue; + } + Vec3 at = plane.toWorld(part.position()).add(blast.x, blast.y, blast.z); + BlockPos ahead = BlockPos.containing(at.x + blast.x * 3.0, at.y + blast.y * 3.0, at.z + blast.z * 3.0); + float spread = plane.level().getBlockState(ahead).isAir() ? 0.7F : 2.0F; + plane.level().addAlwaysVisibleParticle(new SmokeParticleOptions(WHITE, 5.0F * def.particlesScale, 30), true, at.x, at.y, at.z, + blast.x + (random.nextFloat() - 0.5F) * spread, blast.y, blast.z + (random.nextFloat() - 0.5F) * spread); + } + } + + private static void landingGear(PlaneEntity plane) { + double dx = plane.getX() - plane.xo; + double dz = plane.getZ() - plane.zo; + if (!plane.onGround() || dx * dx + dz * dz <= 0.01) { + return; + } + BlockPos below = BlockPos.containing(plane.getX(), plane.getY() - 0.5, plane.getZ()); + BlockState state = plane.level().getBlockState(below); + if (state.getRenderShape() == RenderShape.INVISIBLE) { + return; + } + RandomSource random = plane.getRandom(); + float width = plane.getBbWidth(); + plane.level().addParticle(new BlockParticleOption(ParticleTypes.BLOCK, state), + plane.getX() + (random.nextFloat() - 0.5) * width, plane.getBoundingBox().minY + 0.1, plane.getZ() + (random.nextFloat() - 0.5) * width, + -dx * 4.0, 1.5, -dz * 4.0); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java new file mode 100644 index 0000000..0ad6fda --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ThrowableEntity.java @@ -0,0 +1,199 @@ +package com.jnhprojects.mcheli.entity.projectile; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.weapon.Explosions; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.ARGB; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.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 thrown grenade, {@code MCH_EntityThrowable}. It flies under its definition's gravity, bounces + * off blocks losing speed, and when its fuse runs out either explodes or, for a smoke grenade, + * pours out coloured smoke until it disappears. + */ +public class ThrowableEntity extends Entity { + private static final EntityDataAccessor DATA_NAME = SynchedEntityData.defineId(ThrowableEntity.class, EntityDataSerializers.STRING); + + private @Nullable Entity thrower; + private @Nullable ThrowableDefinition definition; + private int definitionGeneration = -1; + + public ThrowableEntity(EntityType type, Level level) { + super(type, level); + } + + /** + * Throws {@code def} from {@code thrower}'s eyes the way it looks. + * + * @param strength 0.2 for a flick up to 1.4 for a full draw, the original's acceleration + */ + public static ThrowableEntity thrown(Level level, Entity thrower, ThrowableDefinition def, float strength) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + Vec3 look = thrower.getLookAngle(); + entity.setPos(thrower.getX() + look.x * 0.3, thrower.getEyeY() - 0.1, thrower.getZ() + look.z * 0.3); + entity.setYRot(thrower.getYRot()); + entity.setDeltaMovement(look.scale(1.5 * strength).add(thrower.getDeltaMovement())); + entity.thrower = thrower; + entity.setDefinition(def); + return entity; + } + + /** One fired from a dispenser: no thrower, straight along {@code direction}. */ + public static ThrowableEntity dispensed(Level level, Vec3 at, Vec3 direction, ThrowableDefinition def) { + ThrowableEntity entity = new ThrowableEntity(ModEntities.THROWABLE.get(), level); + entity.setPos(at); + entity.setDeltaMovement(direction.normalize().scale(def.dispenseAcceleration)); + entity.setDefinition(def); + return entity; + } + + private void setDefinition(ThrowableDefinition def) { + entityData.set(DATA_NAME, def.name()); + definition = def; + definitionGeneration = MCHeli.content().generation(); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_NAME, ""); + } + + /** The definition by name, looked up again after a content reload. */ + public @Nullable ThrowableDefinition definition() { + if (definitionGeneration != MCHeli.content().generation()) { + definition = MCHeli.content().throwable(entityData.get(DATA_NAME)); + definitionGeneration = MCHeli.content().generation(); + } + return definition; + } + + public String definitionName() { + return entityData.get(DATA_NAME); + } + + @Override + public void tick() { + super.tick(); + ThrowableDefinition def = definition(); + if (def == null) { + // The original gave a missing definition ten ticks to arrive. + if (tickCount > 10 && !level().isClientSide()) { + discard(); + } + return; + } + if (level().isClientSide()) { + if (def.smokes() && tickCount >= def.timeFuse) { + smoke(def); + } + return; + } + if (tickCount >= def.timeFuse && !def.smokes()) { + Explosions.explode((ServerLevel) level(), getX(), getY(), getZ(), new Explosions.Params(this, thrower, + def.explosion, def.explosion, false, true, 0, isInWater(), null)); + discard(); + return; + } + if (tickCount >= def.aliveTime) { + discard(); + return; + } + fly(def); + } + + /** {@code onUpdate} and {@code boundBullet}: gravity of its own, and a bounce off whatever it hits. */ + private void fly(ThrowableDefinition def) { + boolean water = isInWater(); + Vec3 m = getDeltaMovement().add(0.0, water ? def.gravityInWater : def.gravity, 0.0); + Vec3 from = position(); + move(MoverType.SELF, m); + // Whichever axis came up short hit something. + Vec3 moved = position().subtract(from); + double mx = blocked(moved.x, m.x) ? -m.x * def.bound : m.x; + double my = m.y; + double mz = blocked(moved.z, m.z) ? -m.z * def.bound : m.z; + if (blocked(moved.y, m.y)) { + mx *= 0.9; + mz *= 0.9; + // Off the floor or the ceiling it keeps a share of its speed, unless that would be a crawl. + my = Math.abs(my) > 0.05 ? -my * def.bound : 0.0; + } + double drag = water ? 0.8 : 0.99; + setDeltaMovement(mx * drag, my * drag, mz * drag); + } + + private static boolean blocked(double moved, double wanted) { + return Math.abs(moved) < Math.abs(wanted) - 1.0E-6; + } + + private void smoke(ThrowableDefinition def) { + int color = def.smokeColor; + for (int i = 0; i < def.smokeNum; i++) { + // A little noise per puff, keeping greys grey. + float r = ARGB.redFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float g = ARGB.greenFloat(color) * 0.9F + random.nextFloat() * 0.1F; + float b = ARGB.blueFloat(color) * 0.9F + random.nextFloat() * 0.1F; + if (ARGB.red(color) == ARGB.green(color)) { + g = r; + } + if (ARGB.red(color) == ARGB.blue(color)) { + b = r; + } + if (ARGB.green(color) == ARGB.blue(color)) { + b = g; + } + float size = def.smokeSize + random.nextFloat() * def.smokeSize / 3.0F; + level().addAlwaysVisibleParticle(new SmokeParticleOptions(ARGB.colorFromFloat(1.0F, r, g, b) & 0xFFFFFF, size), true, + getX(), getY() + 0.5, getZ(), + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2, + def.smokeVelocityVertical * random.nextFloat() * 0.2, + def.smokeVelocityHorizontal * (random.nextFloat() - 0.5F) * 0.2); + } + } + + /** The server moves it; clients ease towards each position it sends. */ + @Override + protected InterpolationHandler createInterpolationHandler() { + return LinearInterpolationHandler.create(this, 3); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + return false; + } + + @Override + public boolean shouldRenderAtSqrDistance(double distance) { + return distance < 128.0 * 128.0; + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + discard(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + } + + @Override + public boolean shouldBeSaved() { + return false; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java new file mode 100644 index 0000000..2f18676 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ChainItem.java @@ -0,0 +1,85 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.entity.ChainEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; + +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * The tow chain, {@code MCH_ItemChain}. Use it on both ends, in either order, two to sixteen + * blocks apart: the vehicle pulls, so one end must be a vehicle. Use it on either end of a chain + * to take the chain off, or on the hooked end again to let go of it. Vehicles and containers + * hand the click over here, and every step is told to the player above the hotbar. + */ +public class ChainItem extends Item { + private static final double MIN_DISTANCE = 2.0; + private static final double MAX_DISTANCE = 16.0; + + /** The first end each player has hooked, until they hook the second. Server only. */ + private static final Map HOOKED = new WeakHashMap<>(); + + public ChainItem(Properties properties) { + super(properties.stacksTo(1)); + } + + /** A click with the chain on {@code target}. */ + public static InteractionResult useOn(Entity target, Player player) { + if (!(player.level() instanceof ServerLevel level)) { + return InteractionResult.SUCCESS; + } + ChainEntity existing = ChainEntity.on(target); + if (existing != null) { + MCHeli.LOGGER.debug("Chain: {} takes the chain off {}", player.getName().getString(), target); + existing.disconnect(); + HOOKED.remove(player); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.removed")); + return InteractionResult.SUCCESS; + } + UUID firstId = HOOKED.get(player); + Entity first = firstId != null ? level.getEntity(firstId) : null; + // An end hooked far away was most likely forgotten: start over from this one. + if (first == null || first.isRemoved() || target.distanceTo(first) > MAX_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} hooks {}", player.getName().getString(), target); + HOOKED.put(player, target.getUUID()); + ModSounds.playAt(target, "chain", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.hooked", target.getDisplayName())); + return InteractionResult.SUCCESS; + } + if (first == target) { + MCHeli.LOGGER.debug("Chain: {} lets go of {}", player.getName().getString(), target); + HOOKED.remove(player); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.released")); + return InteractionResult.SUCCESS; + } + double distance = target.distanceTo(first); + if (distance < MIN_DISTANCE) { + MCHeli.LOGGER.debug("Chain: {} and {} are only {} blocks apart", first, target, distance); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.too_close", String.format("%.1f", distance))); + return InteractionResult.FAIL; + } + if (!ChainEntity.canTow(first) && !ChainEntity.canTow(target)) { + MCHeli.LOGGER.debug("Chain: neither {} nor {} can tow", first, target); + ModSounds.playAt(player, "ng", 1.0F, 1.0F); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.no_vehicle")); + return InteractionResult.FAIL; + } + MCHeli.LOGGER.debug("Chain: {} chains {} and {}", player.getName().getString(), first, target); + ChainEntity chain = ChainEntity.connect(level, first, target); + HOOKED.remove(player); + Entity towed = chain.towed(); + Entity tow = chain.tow(); + player.sendOverlayMessage(Component.translatable("message.mcheli.chain.connected", + towed != null ? towed.getDisplayName() : first.getDisplayName(), tow != null ? tow.getDisplayName() : target.getDisplayName())); + return InteractionResult.SUCCESS; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java new file mode 100644 index 0000000..a83ff5b --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/LauncherItem.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +import java.util.function.Supplier; + +/** + * A shoulder launched missile, {@code MCH_ItemLightWeaponBase}: the FIM-92 Stinger and the + * FGM-148 Javelin. Held up it is a sight that locks onto targets; the attack key fires once + * locked. It holds one missile, and its bar shows whether it is loaded: an empty launcher reloads + * from a missile in the inventory after two seconds held but not raised. + */ +public class LauncherItem extends Item { + /** The original's durability: 0 is loaded, the maximum is empty. */ + private static final int EMPTY = 10; + + private final String weapon; + private final Supplier missile; + + /** + * @param weapon the weapon definition it fires, such as {@code fim92} + * @param missile the item it reloads from + */ + public LauncherItem(String weapon, Supplier missile, Properties properties) { + super(properties.durability(EMPTY)); + this.weapon = weapon; + this.missile = missile; + } + + public String weapon() { + return weapon; + } + + public Item missile() { + return missile.get(); + } + + public static boolean isLoaded(ItemStack stack) { + return stack.getDamageValue() < stack.getMaxDamage(); + } + + public static void setLoaded(ItemStack stack, boolean loaded) { + stack.setDamageValue(loaded ? 0 : stack.getMaxDamage()); + } + + /** Not from a vehicle seat or the GLTD, as in the original. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity) && !(player.getVehicle() instanceof GltdEntity); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java new file mode 100644 index 0000000..34c524d --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/PlacedEntityItem.java @@ -0,0 +1,62 @@ +package com.jnhprojects.mcheli.item; + +import net.minecraft.core.BlockPos; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; + +import org.jspecify.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * Sets up a piece of equipment that lives in the world as an entity: the GLTD, the container, the + * UAV station. Like the originals' items it goes on top of the block clicked, facing the way the + * player looks, and only where it fits. + */ +public class PlacedEntityItem extends Item { + private final Supplier> type; + + public PlacedEntityItem(Supplier> type, Properties properties) { + super(properties); + this.type = type; + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Level level = context.getLevel(); + Player player = context.getPlayer(); + BlockPos above = context.getClickedPos().above(); + Entity entity = type.get().create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (entity == null) { + return InteractionResult.FAIL; + } + entity.snapTo(above.getX() + 0.5, above.getY(), above.getZ() + 0.5, player != null ? player.getYRot() : 0.0F, 0.0F); + prepare(entity, player); + if (!level.noCollision(entity, entity.getBoundingBox().deflate(0.1)) || !level.getEntities(entity, entity.getBoundingBox()).isEmpty()) { + return InteractionResult.FAIL; + } + if (!level.isClientSide()) { + level.addFreshEntity(entity); + level.gameEvent(player, GameEvent.ENTITY_PLACE, entity.position()); + } + ItemStack stack = context.getItemInHand(); + if (player != null) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, player); + return InteractionResult.SUCCESS; + } + + /** Sets up the new entity before it is checked for room and spawned. */ + protected void prepare(Entity entity, @Nullable Player player) { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java new file mode 100644 index 0000000..6a4fb99 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/RangefinderItem.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; + +/** + * The laser rangefinder, {@code MCH_ItemRangeFinder}. Held up to the eye it is a scope that reads + * the range to whatever it points at; the attack key spots targets for the team, using one of + * its ten charges. Crafting it with redstone charges it again. + */ +public class RangefinderItem extends Item { + public static final int CHARGES = 10; + + public RangefinderItem(Properties properties) { + super(properties.durability(CHARGES)); + } + + /** Not from a vehicle seat: the vehicle's own sight does that job. */ + public static boolean canUse(Player player) { + return !(player.getVehicle() instanceof AircraftEntity); + } + + /** Charges left, from none to {@link #CHARGES}. */ + public static int charges(ItemStack stack) { + return stack.getMaxDamage() - stack.getDamageValue(); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (!canUse(player)) { + return InteractionResult.PASS; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.SPYGLASS; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java new file mode 100644 index 0000000..df8f33f --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/ThrowableItem.java @@ -0,0 +1,123 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.throwable.ThrowableDefinition; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.dispenser.BlockSource; +import net.minecraft.core.dispenser.DefaultDispenseItemBehavior; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DispenserBlock; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Hand grenades, {@code MCH_ItemThrowable}. Like the vehicles there is one item, and the + * {@link ModDataComponents#THROWABLE} component says which definition a stack throws, so pack + * throwables need no item of their own. Held like a bow: the longer the draw, the further it goes. + */ +public class ThrowableItem extends Item { + private static final int FULL_DRAW = 35; + + public ThrowableItem(Properties properties) { + super(properties); + } + + /** A stack of {@code def}, showing the item model the import wrote for it. */ + public static ItemStack stackOf(ThrowableDefinition def) { + ItemStack stack = new ItemStack(ModItems.THROWABLE.get()); + stack.set(ModDataComponents.THROWABLE.get(), def.name()); + stack.set(DataComponents.MAX_STACK_SIZE, def.stackSize); + if (def.pack().equals(ContentSource.ORIGINAL)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } + return stack; + } + + private static @Nullable ThrowableDefinition definition(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + return name != null ? MCHeli.content().throwable(name) : null; + } + + @Override + public Component getName(ItemStack stack) { + String name = stack.get(ModDataComponents.THROWABLE.get()); + if (name == null) { + return super.getName(stack); + } + ThrowableDefinition def = MCHeli.content().throwable(name); + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + if (definition(player.getItemInHand(hand)) == null) { + return InteractionResult.FAIL; + } + player.startUsingItem(hand); + return InteractionResult.CONSUME; + } + + @Override + public int getUseDuration(ItemStack stack, LivingEntity user) { + return 72000; + } + + @Override + public ItemUseAnimation getUseAnimation(ItemStack stack) { + return ItemUseAnimation.BOW; + } + + @Override + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTime) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return false; + } + int drawn = Math.max(getUseDuration(stack, entity) - remainingTime, 5); + float strength = drawn <= FULL_DRAW ? drawn / 25.0F : 1.0F; + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.ARROW_SHOOT, SoundSource.PLAYERS, + 0.5F, 0.4F / (level.getRandom().nextFloat() * 0.4F + 0.8F)); + if (!level.isClientSide()) { + level.addFreshEntity(ThrowableEntity.thrown(level, entity, def, strength)); + } + if (entity instanceof Player player) { + player.awardStat(Stats.ITEM_USED.get(this)); + } + stack.consume(1, entity); + return true; + } + + /** Dispensers throw them straight out, {@code MCH_ItemThrowableDispenseBehavior}. */ + public static class DispenseBehavior extends DefaultDispenseItemBehavior { + @Override + protected ItemStack execute(BlockSource source, ItemStack stack) { + ThrowableDefinition def = definition(stack); + if (def == null) { + return super.execute(source, stack); + } + Direction facing = source.state().getValue(DispenserBlock.FACING); + Vec3 at = source.center().add(facing.getStepX() * 0.7, facing.getStepY() * 0.7, facing.getStepZ() * 0.7); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY() + 0.1, facing.getStepZ()); + source.level().addFreshEntity(ThrowableEntity.dispensed(source.level(), at, direction, def)); + stack.shrink(1); + return stack; + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java new file mode 100644 index 0000000..7e3bc1c --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/item/UavStationItem.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station and the portable UAV controller, {@code MCH_ItemUavStation}. Both place the same + * entity; the kind decides its model and which drones it launches. + */ +public class UavStationItem extends PlacedEntityItem { + private final int kind; + + public UavStationItem(int kind, Properties properties) { + super(ModEntities.UAV_STATION, properties); + this.kind = kind; + } + + /** Squared to the nearest quarter turn, facing the player, as the original placed it. */ + @Override + protected void prepare(Entity entity, @Nullable Player player) { + if (entity instanceof UavStationEntity station) { + station.setKind(kind); + float yaw = player != null ? player.getYRot() : 0.0F; + int quarter = Math.floorMod(Math.round(Mth.wrapDegrees(yaw) / 90.0F), 4); + station.setYRot(quarter * 90.0F - 180.0F); + station.initOffset(); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java new file mode 100644 index 0000000..ec1b4d8 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/menu/DraftingTableMenu.java @@ -0,0 +1,148 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.registry.ModBlocks; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.ResultContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; + +import java.util.List; + +/** + * The drafting table, {@code MCH_DraftingTableGuiContainer}: the player's inventory and one output + * slot. The screen browses the MCHeli recipes in the player's recipe book and asks for one by its + * display id; the server finds the recipe, takes the ingredients from the inventory and puts the + * result in the output slot. + */ +public class DraftingTableMenu extends AbstractContainerMenu { + public static final int OUTPUT_X = 178; + public static final int OUTPUT_Y = 90; + + private final ContainerLevelAccess access; + private final ResultContainer output = new ResultContainer(); + private final int outputSlot; + + /** The client side, from the open screen packet. */ + public DraftingTableMenu(int containerId, Inventory inventory) { + this(containerId, inventory, ContainerLevelAccess.NULL); + } + + public DraftingTableMenu(int containerId, Inventory inventory, ContainerLevelAccess access) { + super(ModMenus.DRAFTING_TABLE.get(), containerId); + this.access = access; + addStandardInventorySlots(inventory, 30, 140); + outputSlot = slots.size(); + addSlot(new Slot(output, 0, OUTPUT_X, OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + } + + /** + * {@code createRecipeItem}: makes one of the recipe behind {@code id}, if it is ours, the output + * slot is free and the player carries the ingredients. Creative players need neither. + */ + public void craft(ServerPlayer player, RecipeDisplayId id) { + boolean creative = player.getAbilities().instabuild; + Slot slot = slots.get(outputSlot); + if (slot.hasItem() && !creative) { + return; + } + RecipeManager.ServerDisplayInfo info = player.level().getServer().getRecipeManager().getRecipeFromDisplay(id); + if (info == null) { + return; + } + RecipeHolder holder = info.parent(); + if (!holder.id().identifier().getNamespace().equals(MCHeli.MOD_ID) || !(holder.value() instanceof CraftingRecipe recipe)) { + return; + } + List ingredients = recipe.placementInfo().ingredients(); + if (!creative && !take(player.getInventory(), ingredients, true)) { + return; + } + if (!creative) { + take(player.getInventory(), ingredients, false); + } + slot.set(recipe.assemble(CraftingInput.EMPTY)); + broadcastChanges(); + } + + /** + * Finds, and unless {@code dryRun} removes, one item per ingredient. A stack serves several + * ingredients as far as its count goes. + */ + private static boolean take(Inventory inventory, List ingredients, boolean dryRun) { + int[] used = new int[inventory.getContainerSize()]; + for (Ingredient ingredient : ingredients) { + int found = -1; + for (int i = 0; i < inventory.getContainerSize() && found < 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty() && stack.getCount() > used[i] && ingredient.test(stack)) { + found = i; + } + } + if (found < 0) { + return false; + } + used[found]++; + } + if (!dryRun) { + for (int i = 0; i < used.length; i++) { + if (used[i] > 0) { + inventory.removeItem(i, used[i]); + } + } + } + return true; + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, ModBlocks.DRAFTING_TABLE.get()); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (index != outputSlot) { + return ItemStack.EMPTY; + } + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (!moveItemStackTo(stack, 0, outputSlot, true)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** What is left in the output goes back to the player, as in the original. */ + @Override + public void removed(Player player) { + super.removed(player); + if (!player.level().isClientSide()) { + clearContainer(player, output); + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java new file mode 100644 index 0000000..4c5f7bd --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/menu/UavStationMenu.java @@ -0,0 +1,154 @@ +package com.jnhprojects.mcheli.menu; + +import com.jnhprojects.mcheli.entity.UavStationEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.registry.ModMenus; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerData; +import net.minecraft.world.inventory.SimpleContainerData; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The UAV station screen, {@code MCH_ContainerUavStation}: one slot for the drone to put up, the + * launch offset with its -10, -1, +1 and +10 buttons per axis, and Continue to take the last drone + * again. The player sits at the station while it is open, so the client finds it as their vehicle. + */ +public class UavStationMenu extends AbstractContainerMenu { + /** Button ids: axis times four plus the step, then Continue. */ + public static final int[] STEPS = {-10, -1, 1, 10}; + public static final int BUTTON_CONTINUE = 12; + + private static final int DATA_SIZE = 4; + private static final int DATA_CAN_CONTINUE = 3; + + private final @Nullable UavStationEntity station; + private final ContainerData data; + + /** The client side, from the open screen packet. */ + public UavStationMenu(int containerId, Inventory inventory) { + this(containerId, inventory, inventory.player.getVehicle() instanceof UavStationEntity s ? s : null, + new SimpleContainer(1), new SimpleContainerData(DATA_SIZE)); + } + + /** The server side. */ + public UavStationMenu(int containerId, Inventory inventory, UavStationEntity station) { + this(containerId, inventory, station, station.slot(), new ServerData(station)); + } + + private UavStationMenu(int containerId, Inventory inventory, @Nullable UavStationEntity station, Container slot, ContainerData data) { + super(ModMenus.UAV_STATION.get(), containerId); + this.station = station; + this.data = data; + checkContainerSize(slot, 1); + addSlot(new Slot(slot, 0, 20, 20) { + @Override + public boolean mayPlace(ItemStack stack) { + return AircraftItem.vehicleOf(stack) != null; + } + + @Override + public int getMaxStackSize() { + return 1; + } + }); + addStandardInventorySlots(inventory, 8, 84); + addDataSlots(data); + } + + public @Nullable UavStationEntity station() { + return station; + } + + /** The launch offset along {@code axis} (0 x, 1 y, 2 z), as the server last reported it. */ + public int offset(int axis) { + return (short) data.get(axis); + } + + public boolean canContinue() { + return data.get(DATA_CAN_CONTINUE) != 0; + } + + @Override + public boolean clickMenuButton(Player player, int id) { + if (station == null || !(player instanceof ServerPlayer server)) { + return false; + } + if (id == BUTTON_CONTINUE) { + return station.continueControl(server); + } + if (id < 0 || id >= 12) { + return false; + } + int[] pos = {station.offset().getX(), station.offset().getY(), station.offset().getZ()}; + pos[id / 4] += STEPS[id % 4]; + station.setOffset(new BlockPos(pos[0], pos[1], pos[2])); + return true; + } + + @Override + public boolean stillValid(Player player) { + return station != null && station.isAlive() && player.getVehicle() == station; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack moved = stack.copy(); + if (index == 0) { + if (!moveItemStackTo(stack, 1, slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (AircraftItem.vehicleOf(stack) == null || !moveItemStackTo(stack, 0, 1, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.setByPlayer(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return moved; + } + + /** Whatever is left in the slot goes back to the player, as with a crafting table. */ + @Override + public void removed(Player player) { + super.removed(player); + if (station != null && !player.level().isClientSide()) { + clearContainer(player, station.slot()); + } + } + + private record ServerData(UavStationEntity station) implements ContainerData { + @Override + public int get(int index) { + BlockPos o = station.offset(); + return switch (index) { + case 0 -> o.getX(); + case 1 -> o.getY(); + case 2 -> o.getZ(); + default -> station.lastUav() != null ? 1 : 0; + }; + } + + @Override + public void set(int index, int value) { + } + + @Override + public int getCount() { + return DATA_SIZE; + } + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java index 84f1f3e..1b565e2 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 @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.network; +import com.jnhprojects.mcheli.ModSounds; import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.entity.PlaneEntity; import com.jnhprojects.mcheli.weapon.VehicleWeapons; @@ -50,7 +51,13 @@ public enum Action { /** 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 + NIGHT_VISION_OFF, + /** Let the troops down: ropes from a hover, a parachute drop, or out on the ground. Pilot only. */ + DROP_CREW, + /** Slide down a rope or jump with a parachute. Crew other than the pilot. */ + JUMP_OUT, + /** Turn the search light on or off. Pilot and first crew seat. */ + SEARCH_LIGHT } public static void encode(CrewActionPacket msg, FriendlyByteBuf buf) { @@ -136,6 +143,17 @@ public static void handle(CrewActionPacket msg, ServerPlayer sender) { } case VEHICLE_SCREEN -> vehicle.openScreen(sender); case NIGHT_VISION_ON, NIGHT_VISION_OFF -> vehicle.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + case DROP_CREW -> { + if (pilot && !vehicle.dropCrew()) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } + case SEARCH_LIGHT -> vehicle.toggleSearchLight(sender); + case JUMP_OUT -> { + if (!pilot && !vehicle.jumpOut(sender)) { + ModSounds.playTo(sender, "ng", 1.0F, 1.0F); + } + } } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java new file mode 100644 index 0000000..46294aa --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/DraftingTablePacket.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; + +/** + * The Create button of the drafting table, {@code MCH_DraftingTableCreatePacket}: make one of the + * recipe the player's recipe book knows by this display id. + */ +public record DraftingTablePacket(RecipeDisplayId recipe) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("drafting_table"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(DraftingTablePacket::encode, DraftingTablePacket::decode); + + public static void encode(DraftingTablePacket msg, FriendlyByteBuf buf) { + RecipeDisplayId.STREAM_CODEC.encode(buf, msg.recipe); + } + + public static DraftingTablePacket decode(FriendlyByteBuf buf) { + return new DraftingTablePacket(RecipeDisplayId.STREAM_CODEC.decode(buf)); + } + + public static void handle(DraftingTablePacket msg, ServerPlayer sender) { + if (sender != null && sender.containerMenu instanceof DraftingTableMenu menu && menu.stillValid(sender)) { + menu.craft(sender, msg.recipe); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java new file mode 100644 index 0000000..76090f2 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/EquipmentActionPacket.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.GltdEntity; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * A request from a player using a piece of equipment they sit at, such as the GLTD. Like + * {@link CrewActionPacket} the client only asks; the equipment checks that the sender is the one + * using it. + */ +public record EquipmentActionPacket(int entityId, Action action) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("equipment_action"); + public static final StreamCodec STREAM_CODEC = + StreamCodec.ofMember(EquipmentActionPacket::encode, EquipmentActionPacket::decode); + + public enum Action { + /** Mark the spot under the sight and call in the A-10. */ + CALL_STRIKE, + /** Turn night vision on for the sender. */ + NIGHT_VISION_ON, + /** Turn the sender's night vision off again. */ + NIGHT_VISION_OFF + } + + public static void encode(EquipmentActionPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.entityId); + buf.writeVarInt(msg.action.ordinal()); + } + + public static EquipmentActionPacket decode(FriendlyByteBuf buf) { + int entityId = buf.readVarInt(); + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown equipment action " + ordinal); + } + return new EquipmentActionPacket(entityId, actions[ordinal]); + } + + public static void handle(EquipmentActionPacket msg, ServerPlayer sender) { + if (sender == null || sender.getVehicle() == null || sender.getVehicle().getId() != msg.entityId) { + return; + } + if (sender.getVehicle() instanceof GltdEntity gltd) { + switch (msg.action) { + case CALL_STRIKE -> gltd.callStrike(sender); + case NIGHT_VISION_ON, NIGHT_VISION_OFF -> gltd.setNightVision(sender, msg.action == Action.NIGHT_VISION_ON); + } + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java new file mode 100644 index 0000000..d3f204c --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/LauncherPacket.java @@ -0,0 +1,119 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponType; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.item.LauncherItem; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** + * The player with a launcher in hand, {@code MCH_PacketLightWeaponPlayerControl}: fire at the + * locked target, or finish a reload. The client locks and times the reload; the server checks + * the launcher, the target and the missile in the inventory. + * + * @param target the locked entity, for {@link Action#FIRE} + * @param mode the Javelin's attack mode: 0 direct, 1 top attack + */ +public record LauncherPacket(Action action, int target, int mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("launcher"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(LauncherPacket::encode, LauncherPacket::decode); + + /** Locks further than this are refused, with some slack over the lock range for lag. */ + private static final double MAX_TARGET_DISTANCE = 260.0; + + public enum Action { + FIRE, + RELOAD + } + + public static void encode(LauncherPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeVarInt(msg.target); + buf.writeVarInt(msg.mode); + } + + public static LauncherPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown launcher action " + ordinal); + } + return new LauncherPacket(actions[ordinal], buf.readVarInt(), buf.readVarInt()); + } + + public static void handle(LauncherPacket msg, ServerPlayer sender) { + ItemStack stack = sender == null ? ItemStack.EMPTY : sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof LauncherItem launcher) || !LauncherItem.canUse(sender)) { + return; + } + switch (msg.action) { + case FIRE -> fire(sender, stack, launcher, msg.target, msg.mode); + case RELOAD -> reload(sender, stack, launcher); + } + } + + private static void fire(ServerPlayer player, ItemStack stack, LauncherItem launcher, int targetId, int mode) { + WeaponDefinition def = MCHeli.content().weapon(launcher.weapon()); + ServerLevel level = player.level(); + Entity target = level.getEntity(targetId); + if (def == null || !LauncherItem.isLoaded(stack) || target == null || target.isRemoved() + || target.distanceToSqr(player) > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE) { + return; + } + ProjectileEntity missile = def.type == WeaponType.AT_MISSILE + ? ModEntities.AT_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED) + : ModEntities.AA_MISSILE.get().create(level, EntitySpawnReason.TRIGGERED); + if (missile == null) { + return; + } + float yaw = player.getYRot(); + float pitch = player.getXRot(); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + Vec3 at = player.getEyePosition().add(dir); + missile.launch(at.x, at.y, at.z, dir.x, dir.y, dir.z, yaw, pitch, def.acceleration); + missile.setWeapon(launcher.weapon()); + missile.setParameters(def, player, player); + missile.setTarget(target); + if (missile instanceof ATMissileEntity atMissile) { + atMissile.guidanceType = Math.clamp(mode, 0, Math.max(def.modeNum - 1, 0)); + } + level.addFreshEntity(missile); + if (def.sound != null) { + ModSounds.playAt(player, def.sound, def.soundVolume, def.soundPitch); + } + LauncherItem.setLoaded(stack, false); + } + + private static void reload(ServerPlayer player, ItemStack stack, LauncherItem launcher) { + if (LauncherItem.isLoaded(stack)) { + return; + } + if (!player.getAbilities().instabuild) { + int slot = player.getInventory().findSlotMatchingItem(new ItemStack(launcher.missile())); + if (slot < 0) { + return; + } + player.getInventory().removeItem(slot, 1); + } + LauncherItem.setLoaded(stack, true); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java new file mode 100644 index 0000000..1de0bfc --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/MarkerPacket.java @@ -0,0 +1,53 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.Markers; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Server to client: the team spotted these entities for {@code ticks}, or marked {@code point}, + * {@code MCH_PacketNotifySpotedEntity} and {@code MCH_PacketNotifyMarkPoint}. Only the spotter and + * their teammates get it. + */ +public record MarkerPacket(int[] entities, int ticks, BlockPos point) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("marker"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(MarkerPacket::encode, MarkerPacket::decode); + /** Spotting sends no point; this stands in for none. */ + private static final BlockPos NO_POINT = new BlockPos(0, Integer.MIN_VALUE, 0); + private static final int MAX_ENTITIES = 256; + + public static MarkerPacket spotted(int[] entities, int ticks) { + return new MarkerPacket(entities, ticks, NO_POINT); + } + + public static MarkerPacket point(BlockPos point) { + return new MarkerPacket(new int[0], 0, point); + } + + public static void encode(MarkerPacket msg, FriendlyByteBuf buf) { + buf.writeVarIntArray(msg.entities); + buf.writeVarInt(msg.ticks); + buf.writeBlockPos(msg.point); + } + + public static MarkerPacket decode(FriendlyByteBuf buf) { + return new MarkerPacket(buf.readVarIntArray(MAX_ENTITIES), buf.readVarInt(), buf.readBlockPos()); + } + + public static void handle(MarkerPacket msg) { + if (msg.entities.length > 0) { + Markers.spotted(msg.entities, msg.ticks); + } + if (!msg.point.equals(NO_POINT)) { + Markers.point(msg.point); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java index d8eb5e9..efa411d 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,12 +21,17 @@ */ public final class ModNetwork { /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - public static final int PROTOCOL_VERSION = 8; + public static final int PROTOCOL_VERSION = 9; /** Client to server. The handler gets the sending player. */ public static final List> SERVERBOUND = List.of( new Serverbound<>(PilotStatePacket.TYPE, PilotStatePacket.STREAM_CODEC, PilotStatePacket::handle), new Serverbound<>(CrewActionPacket.TYPE, CrewActionPacket.STREAM_CODEC, CrewActionPacket::handle), + new Serverbound<>(EquipmentActionPacket.TYPE, EquipmentActionPacket.STREAM_CODEC, EquipmentActionPacket::handle), + new Serverbound<>(SpotPacket.TYPE, SpotPacket.STREAM_CODEC, SpotPacket::handle), + new Serverbound<>(LauncherPacket.TYPE, LauncherPacket.STREAM_CODEC, LauncherPacket::handle), + new Serverbound<>(DraftingTablePacket.TYPE, DraftingTablePacket.STREAM_CODEC, DraftingTablePacket::handle), + new Serverbound<>(TeamPacket.TYPE, TeamPacket.STREAM_CODEC, TeamPacket::handle), new Serverbound<>(FireWeaponPacket.TYPE, FireWeaponPacket.STREAM_CODEC, FireWeaponPacket::handle), new Serverbound<>(BoardVehiclePacket.TYPE, BoardVehiclePacket.STREAM_CODEC, BoardVehiclePacket::handle), new Serverbound<>(LockWarningPacket.TYPE, LockWarningPacket.STREAM_CODEC, LockWarningPacket::handle), @@ -37,7 +42,8 @@ public final class ModNetwork { new Clientbound<>(WeaponStatePacket.TYPE, WeaponStatePacket.STREAM_CODEC, WeaponStatePacket::handle), new Clientbound<>(ExplosionEffectPacket.TYPE, ExplosionEffectPacket.STREAM_CODEC, ExplosionEffectPacket::handle), new Clientbound<>(PackListPacket.TYPE, PackListPacket.STREAM_CODEC, PackListPacket::handle), - new Clientbound<>(PackDataPacket.TYPE, PackDataPacket.STREAM_CODEC, PackDataPacket::handle)); + new Clientbound<>(PackDataPacket.TYPE, PackDataPacket.STREAM_CODEC, PackDataPacket::handle), + new Clientbound<>(MarkerPacket.TYPE, MarkerPacket.STREAM_CODEC, MarkerPacket::handle)); private ModNetwork() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java new file mode 100644 index 0000000..1ee7228 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/SpotPacket.java @@ -0,0 +1,78 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.weapon.Spotting; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; + +/** + * The attack key through a rangefinder, {@code MCH_PacketIndSpotEntity}: spot players and + * vehicles, spot mobs, or mark a point for the team. Spotting uses a charge; marking is free. + */ +public record SpotPacket(Mode mode) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("spot"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(SpotPacket::encode, SpotPacket::decode); + + /** The rangefinder's modes, in the order its mode key steps through them. */ + public enum Mode { + PLAYERS_AND_VEHICLES(Spotting.PLAYERS | Spotting.GROUND_VEHICLES | Spotting.HELICOPTERS | Spotting.PLANES), + MOBS(Spotting.OTHERS | Spotting.MONSTERS), + MARK_POINT(0); + + final int filter; + + Mode(int filter) { + this.filter = filter; + } + } + + public static void encode(SpotPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.mode.ordinal()); + } + + public static SpotPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Mode[] modes = Mode.values(); + if (ordinal < 0 || ordinal >= modes.length) { + throw new IllegalArgumentException("Unknown spot mode " + ordinal); + } + return new SpotPacket(modes[ordinal]); + } + + public static void handle(SpotPacket msg, ServerPlayer sender) { + if (sender == null || !RangefinderItem.canUse(sender)) { + return; + } + ItemStack stack = sender.getItemInHand(InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + stack = sender.getItemInHand(InteractionHand.OFF_HAND); + if (!(stack.getItem() instanceof RangefinderItem)) { + return; + } + } + boolean done; + if (msg.mode == Mode.MARK_POINT) { + done = Spotting.markPoint(sender); + } else if (RangefinderItem.charges(stack) > 0) { + if (!sender.getAbilities().instabuild) { + // An empty rangefinder stays: it only needs redstone, unlike a worn out tool. + stack.setDamageValue(stack.getDamageValue() + 1); + } + done = Spotting.spot(sender, msg.mode.filter); + } else { + done = false; + } + ModSounds.playAt(sender, done ? "pi" : "ng", 1.0F, 1.0F); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java new file mode 100644 index 0000000..14b2db4 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/network/TeamPacket.java @@ -0,0 +1,66 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; + +/** + * A button of the team manager, {@code MCH_PacketIndMultiplayCommand}. Operators only; the + * server checks again. + * + * @param name the new team's name, for {@link Action#CREATE_TEAM} + * @param color the new team's colour, as a vanilla team colour id + */ +public record TeamPacket(Action action, String name, int color, boolean friendlyFire) implements CustomPacketPayload { + public static final Type TYPE = ModNetwork.type("team"); + public static final StreamCodec STREAM_CODEC = StreamCodec.ofMember(TeamPacket::encode, TeamPacket::decode); + + public enum Action { + PVP, + SHUFFLE, + CREATE_TEAM, + JUMP_TO_SPAWN, + DESTROY_ALL + } + + public TeamPacket(Action action) { + this(action, "", 0, false); + } + + public static void encode(TeamPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.action.ordinal()); + buf.writeUtf(msg.name, 16); + buf.writeVarInt(msg.color); + buf.writeBoolean(msg.friendlyFire); + } + + public static TeamPacket decode(FriendlyByteBuf buf) { + int ordinal = buf.readVarInt(); + Action[] actions = Action.values(); + if (ordinal < 0 || ordinal >= actions.length) { + throw new IllegalArgumentException("Unknown team action " + ordinal); + } + return new TeamPacket(actions[ordinal], buf.readUtf(16), buf.readVarInt(), buf.readBoolean()); + } + + public static void handle(TeamPacket msg, ServerPlayer sender) { + if (sender == null || !TeamManager.mayManage(sender)) { + return; + } + switch (msg.action) { + case PVP -> TeamManager.togglePvp(sender); + case SHUFFLE -> TeamManager.shuffle(sender); + case CREATE_TEAM -> TeamManager.create(sender, msg.name, msg.color, msg.friendlyFire); + case JUMP_TO_SPAWN -> TeamManager.jumpToSpawn(sender); + case DESTROY_ALL -> TeamManager.destroyAll(sender); + } + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java new file mode 100644 index 0000000..29a0b0e --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/particle/SmokeParticleOptions.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.util.ExtraCodecs; + +/** + * A puff of coloured smoke that swells and pales as it drifts, {@code MCH_EntityParticleSmoke} + * with {@code toWhite} set. The smoke grenades use it to mark a spot. + * + * @param color RGB the puff starts with + * @param size the original's particle size; a puff ends up about {@code size / 5} blocks across + * @param lifetime ticks the puff lasts, or 0 for the grenade smoke's three to five seconds + */ +public record SmokeParticleOptions(int color, float size, int lifetime) implements ParticleOptions { + public SmokeParticleOptions(int color, float size) { + this(color, size, 0); + } + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + ExtraCodecs.RGB_COLOR_CODEC.fieldOf("color").forGetter(SmokeParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(SmokeParticleOptions::size), + ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("lifetime", 0).forGetter(SmokeParticleOptions::lifetime)) + .apply(i, SmokeParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, SmokeParticleOptions::color, + ByteBufCodecs.FLOAT, SmokeParticleOptions::size, + ByteBufCodecs.VAR_INT, SmokeParticleOptions::lifetime, + SmokeParticleOptions::new); + + @Override + public ParticleType getType() { + return ModParticles.SMOKE.get(); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java new file mode 100644 index 0000000..b03984c --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModBlockEntities.java @@ -0,0 +1,22 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlockEntity; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.entity.BlockEntityType; + +import java.util.Set; + +/** Our block entities: the drafting table's, which draws its model. */ +public final class ModBlockEntities { + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.BLOCK_ENTITY_TYPE, + "drafting_table", key -> new BlockEntityType<>(DraftingTableBlockEntity::new, Set.of(ModBlocks.DRAFTING_TABLE.get()))); + + private ModBlockEntities() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java new file mode 100644 index 0000000..19995a9 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModBlocks.java @@ -0,0 +1,26 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.block.DraftingTableBlock; +import com.jnhprojects.mcheli.platform.Registered; +import com.jnhprojects.mcheli.platform.Registration; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Our blocks: the drafting table, lit or not. */ +public final class ModBlocks { + public static final Registered DRAFTING_TABLE = Registration.register(Registries.BLOCK, "drafting_table", + key -> new DraftingTableBlock(BlockBehaviour.Properties.of().setId(key) + .strength(0.2F) + .sound(SoundType.METAL) + .noOcclusion() + .lightLevel(state -> state.getValue(DraftingTableBlock.LIT) ? 15 : 0))); + + private ModBlocks() { + } + + /** Declares the entries; the loader module registers them. */ + public static void init() { + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java index fdd851d..870aa8b 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 @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ThrowableItem; import com.jnhprojects.mcheli.platform.Platform; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; @@ -17,8 +18,8 @@ /** * 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. + * the equipment and throwables. Those lists are read from the content registry whenever a tab is + * built, so reloaded content shows up. */ public final class ModCreativeTabs { public static final Registered HELICOPTERS = register("helicopters", "helicopter", "ah-64", @@ -30,7 +31,10 @@ public final class ModCreativeTabs { 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()))) + .displayItems((params, output) -> { + ModItems.EQUIPMENT.forEach(item -> output.accept(item.get())); + MCHeli.content().throwables().forEach(def -> output.accept(ThrowableItem.stackOf(def))); + }) .build()); private ModCreativeTabs() { @@ -59,7 +63,7 @@ private static Registered register(String name, String kind, St /** Our tabs, for rebuilding their contents after a content reload. */ public static List all() { - return List.of(HELICOPTERS.get(), PLANES.get(), TANKS.get(), VEHICLES.get()); + return List.of(HELICOPTERS.get(), PLANES.get(), TANKS.get(), VEHICLES.get(), EQUIPMENT.get()); } /** Declares the entries; the loader module registers them. */ diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java index 08b564e..377a578 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -18,6 +18,13 @@ public final class ModDataComponents { .networkSynchronized(ByteBufCodecs.STRING_UTF8) .build()); + /** The definition a throwable item throws, such as {@code m18_red}. */ + public static final Registered> THROWABLE = Registration.register(Registries.DATA_COMPONENT_TYPE, "throwable", + key -> DataComponentType.builder() + .persistent(Codec.STRING) + .networkSynchronized(ByteBufCodecs.STRING_UTF8) + .build()); + private ModDataComponents() { } diff --git a/minecraft-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 c881124..b6686cc 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 @@ -5,11 +5,16 @@ import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; import com.jnhprojects.mcheli.content.aircraft.TankDefinition; import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.ChainEntity; +import com.jnhprojects.mcheli.entity.ContainerEntity; +import com.jnhprojects.mcheli.entity.GltdEntity; import com.jnhprojects.mcheli.entity.GroundVehicleEntity; import com.jnhprojects.mcheli.entity.HelicopterEntity; import com.jnhprojects.mcheli.entity.ParachuteEntity; import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.entity.RappelEntity; import com.jnhprojects.mcheli.entity.TankEntity; +import com.jnhprojects.mcheli.entity.UavStationEntity; import com.jnhprojects.mcheli.entity.projectile.A10Entity; import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; @@ -22,6 +27,7 @@ import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; import com.jnhprojects.mcheli.entity.projectile.RocketEntity; import com.jnhprojects.mcheli.entity.projectile.TorpedoEntity; +import com.jnhprojects.mcheli.entity.projectile.ThrowableEntity; import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; @@ -73,6 +79,54 @@ public final class ModEntities { .updateInterval(1) .build(key)); + public static final Registered> THROWABLE = Registration.register(Registries.ENTITY_TYPE, "throwable", key -> EntityType.Builder + .of(ThrowableEntity::new, MobCategory.MISC) + .sized(0.25F, 0.25F) + .clientTrackingRange(8) + .updateInterval(2) + .noSave() + .build(key)); + + public static final Registered> GLTD = Registration.register(Registries.ENTITY_TYPE, "gltd", key -> EntityType.Builder + .of(GltdEntity::new, MobCategory.MISC) + .sized(0.5F, 0.5F) + .clientTrackingRange(10) + .updateInterval(3) + .build(key)); + + public static final Registered> CONTAINER = Registration.register(Registries.ENTITY_TYPE, "container", key -> EntityType.Builder + .of(ContainerEntity::new, MobCategory.MISC) + .sized(2.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key)); + + public static final Registered> CHAIN = Registration.register(Registries.ENTITY_TYPE, "chain", key -> EntityType.Builder + .of(ChainEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(2) + .fireImmune() + .build(key)); + + public static final Registered> RAPPEL = Registration.register(Registries.ENTITY_TYPE, "rappel", key -> EntityType.Builder + .of(RappelEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(10) + .updateInterval(1) + .fireImmune() + .noSave() + .build(key)); + + public static final Registered> UAV_STATION = Registration.register(Registries.ENTITY_TYPE, "uav_station", key -> EntityType.Builder + .of(UavStationEntity::new, MobCategory.MISC) + .sized(2.0F, 0.7F) + .clientTrackingRange(10) + .updateInterval(3) + .fireImmune() + .build(key)); + private ModEntities() { } 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 24b3055..e7415df 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 @@ -3,14 +3,22 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.entity.UavStationEntity; import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.ChainItem; import com.jnhprojects.mcheli.item.FuelItem; +import com.jnhprojects.mcheli.item.LauncherItem; import com.jnhprojects.mcheli.item.LegacyAircraftItem; import com.jnhprojects.mcheli.item.ParachuteItem; +import com.jnhprojects.mcheli.item.PlacedEntityItem; +import com.jnhprojects.mcheli.item.RangefinderItem; +import com.jnhprojects.mcheli.item.ThrowableItem; +import com.jnhprojects.mcheli.item.UavStationItem; import com.jnhprojects.mcheli.item.WrenchItem; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; import net.minecraft.world.item.Item; import java.util.List; @@ -19,7 +27,7 @@ /** * One item per vehicle kind; the stack says which vehicle (see {@link AircraftItem}). The 1.0.0 * items, one per vehicle, are kept for the content present at startup so old worlds keep them. The - * equipment (fuel can, wrench, parachute) has one item each. + * equipment (fuel can, wrench, parachute and so on) has one item each, and the throwables share one. */ public final class ModItems { public static final Registered HELICOPTER = kind("helicopter"); @@ -30,9 +38,30 @@ public final class ModItems { public static final Registered FUEL = Registration.register(Registries.ITEM, "fuel", key -> new FuelItem(new Item.Properties().setId(key))); public static final Registered WRENCH = Registration.register(Registries.ITEM, "wrench", key -> new WrenchItem(new Item.Properties().setId(key))); public static final Registered PARACHUTE = Registration.register(Registries.ITEM, "parachute", key -> new ParachuteItem(new Item.Properties().setId(key))); + public static final Registered GLTD = Registration.register(Registries.ITEM, "gltd", + key -> new PlacedEntityItem(ModEntities.GLTD, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered RANGEFINDER = Registration.register(Registries.ITEM, "rangefinder", key -> new RangefinderItem(new Item.Properties().setId(key))); + public static final Registered CONTAINER = Registration.register(Registries.ITEM, "container", + key -> new PlacedEntityItem(ModEntities.CONTAINER, new Item.Properties().setId(key))); + public static final Registered CHAIN = Registration.register(Registries.ITEM, "chain", key -> new ChainItem(new Item.Properties().setId(key))); + public static final Registered STINGER_MISSILE = Registration.register(Registries.ITEM, "fim92_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered STINGER = Registration.register(Registries.ITEM, "fim92", + key -> new LauncherItem("fim92", () -> ModItems.STINGER_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered JAVELIN_MISSILE = Registration.register(Registries.ITEM, "fgm148_bullet", key -> new Item(new Item.Properties().setId(key).stacksTo(16))); + public static final Registered JAVELIN = Registration.register(Registries.ITEM, "fgm148", + key -> new LauncherItem("fgm148", () -> ModItems.JAVELIN_MISSILE.get(), new Item.Properties().setId(key))); + public static final Registered UAV_STATION = Registration.register(Registries.ITEM, "uav_station", + key -> new UavStationItem(UavStationEntity.STATION, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered UAV_CONTROLLER = Registration.register(Registries.ITEM, "uav_station2", + key -> new UavStationItem(UavStationEntity.PORTABLE, new Item.Properties().setId(key).stacksTo(1))); + public static final Registered DRAFTING_TABLE = Registration.register(Registries.ITEM, "drafting_table", + key -> new BlockItem(ModBlocks.DRAFTING_TABLE.get(), new Item.Properties().setId(key).useBlockDescriptionPrefix())); + /** Every throwable; the stack says which (see {@link ThrowableItem}). */ + public static final Registered THROWABLE = Registration.register(Registries.ITEM, "throwable", key -> new ThrowableItem(new Item.Properties().setId(key))); /** The equipment tab, in the order it lists them. */ - public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE); + public static final List> EQUIPMENT = List.of(FUEL, WRENCH, PARACHUTE, GLTD, RANGEFINDER, CONTAINER, CHAIN, + STINGER, STINGER_MISSILE, JAVELIN, JAVELIN_MISSILE, UAV_STATION, UAV_CONTROLLER, DRAFTING_TABLE); private static final Map> BY_KIND = Map.of("helicopter", HELICOPTER, "plane", PLANE, "tank", TANK, "vehicle", VEHICLE); diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java index 58ecce9..0dac005 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModMenus.java @@ -1,5 +1,7 @@ package com.jnhprojects.mcheli.registry; +import com.jnhprojects.mcheli.menu.DraftingTableMenu; +import com.jnhprojects.mcheli.menu.UavStationMenu; import com.jnhprojects.mcheli.menu.VehicleMenu; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; @@ -11,6 +13,10 @@ public final class ModMenus { public static final Registered> VEHICLE = Registration.register(Registries.MENU, "vehicle", key -> new MenuType<>(VehicleMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> DRAFTING_TABLE = Registration.register(Registries.MENU, "drafting_table", + key -> new MenuType<>(DraftingTableMenu::new, FeatureFlags.VANILLA_SET)); + public static final Registered> UAV_STATION = Registration.register(Registries.MENU, "uav_station", + key -> new MenuType<>(UavStationMenu::new, FeatureFlags.VANILLA_SET)); private ModMenus() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java index ce9a1da..4ed53ac 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.registry; import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.jnhprojects.mcheli.particle.SmokeParticleOptions; import com.jnhprojects.mcheli.platform.Registered; import com.jnhprojects.mcheli.platform.Registration; import com.mojang.serialization.MapCodec; @@ -24,6 +25,20 @@ public StreamCodec streamC } }); + /** Smoke grenades: a marker that vanishes under the particle limit is no marker. */ + public static final Registered> SMOKE = Registration.register(Registries.PARTICLE_TYPE, "smoke", + key -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return SmokeParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return SmokeParticleOptions.STREAM_CODEC; + } + }); + private ModParticles() { } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/team/Markers.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/team/Markers.java new file mode 100644 index 0000000..4d86937 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/team/Markers.java @@ -0,0 +1,58 @@ +package com.jnhprojects.mcheli.team; + +import net.minecraft.core.BlockPos; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * What this client's team has spotted, {@code MCH_GuiTargetMarker}: the spotted entities, each for + * as long as it was spotted, and the last marked point. Filled by {@code MarkerPacket} on the + * client's main thread and drawn by the HUD; a dedicated server never touches it. + */ +public final class Markers { + /** A marked point stays up this long, unless another replaces it. */ + public static final int POINT_TICKS = 60 * 20; + + private static final Map SPOTTED = new HashMap<>(); + private static @Nullable BlockPos point; + private static int pointTicks; + + private Markers() { + } + + public static void spotted(int[] entities, int ticks) { + for (int id : entities) { + SPOTTED.merge(id, ticks, Math::max); + } + } + + public static void point(BlockPos pos) { + point = pos; + pointTicks = POINT_TICKS; + } + + /** Entity ids and ticks left. */ + public static Map spotted() { + return SPOTTED; + } + + public static @Nullable BlockPos point() { + return point; + } + + public static void tick() { + SPOTTED.replaceAll((id, ticks) -> ticks - 1); + SPOTTED.values().removeIf(ticks -> ticks <= 0); + if (pointTicks > 0 && --pointTicks == 0) { + point = null; + } + } + + public static void clear() { + SPOTTED.clear(); + point = null; + pointTicks = 0; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java new file mode 100644 index 0000000..b681629 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/team/TeamManager.java @@ -0,0 +1,113 @@ +package com.jnhprojects.mcheli.team; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; +import net.minecraft.world.scores.TeamColor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * The team manager's actions, {@code MCH_Multiplay} and {@code MCH_MultiplayPacketHandler}: for + * operators running a match. Teams are the vanilla scoreboard teams, so {@code /team} and the + * player list see the same ones. + */ +public final class TeamManager { + private TeamManager() { + } + + /** Operators only, as the original checked the scoreboard command's permission. */ + public static boolean mayManage(Player player) { + return player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER); + } + + /** Switches the {@code pvp} game rule in every dimension and says which way it went. */ + public static void togglePvp(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + boolean pvp = !player.level().getGameRules().get(GameRules.PVP); + for (ServerLevel level : server.getAllLevels()) { + level.getGameRules().set(GameRules.PVP, pvp, server); + } + broadcast(server, Component.translatable(pvp ? "message.mcheli.team.pvp_on" : "message.mcheli.team.pvp_off")); + } + + /** {@code shuffleTeam}: every player online goes into one of the teams in turn, in random order. */ + public static void shuffle(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + Scoreboard scoreboard = server.getScoreboard(); + List teams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.isEmpty()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.team.no_teams")); + return; + } + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + Collections.shuffle(players); + Collections.shuffle(teams); + for (int i = 0; i < players.size(); i++) { + scoreboard.addPlayerToTeam(players.get(i).getScoreboardName(), teams.get(i % teams.size())); + } + broadcast(server, Component.translatable("message.mcheli.team.shuffled", teams.size())); + } + + /** The original's create team screen: a name, a colour and friendly fire. */ + public static void create(ServerPlayer player, String name, int color, boolean friendlyFire) { + Scoreboard scoreboard = player.level().getServer().getScoreboard(); + String trimmed = name.trim(); + if (trimmed.isEmpty() || trimmed.length() > 16 || trimmed.contains(" ") || scoreboard.getPlayerTeam(trimmed) != null) { + player.sendOverlayMessage(Component.translatable("message.mcheli.team.bad_name", trimmed)); + return; + } + PlayerTeam team = scoreboard.addPlayerTeam(trimmed); + team.setColor(Optional.of(TeamColor.VALUES.get(Math.floorMod(color, TeamColor.VALUES.size())))); + team.setAllowFriendlyFire(friendlyFire); + player.sendOverlayMessage(Component.translatable("message.mcheli.team.created", trimmed)); + } + + /** {@code jumpSpawnPoint}: every player back to where they would respawn. */ + public static void jumpToSpawn(ServerPlayer player) { + for (ServerPlayer target : List.copyOf(player.level().getServer().getPlayerList().getPlayers())) { + target.stopRiding(); + target.teleport(target.findRespawnPositionAndUseSpawnBlock(false, TeleportTransition.DO_NOTHING)); + } + } + + /** {@code destoryAllAircraft}: every loaded vehicle is gone, its crew left standing. */ + public static void destroyAll(ServerPlayer player) { + int count = 0; + for (ServerLevel level : player.level().getServer().getAllLevels()) { + for (AircraftEntity vehicle : level.getEntities(EntityTypeTest.forClass(AircraftEntity.class), e -> true)) { + vehicle.ejectPassengers(); + vehicle.discard(); + count++; + } + } + player.sendOverlayMessage(Component.translatable("message.mcheli.team.destroyed", count)); + } + + /** {@code entity} and everyone on its team. */ + public static List teammates(ServerPlayer entity) { + List out = new ArrayList<>(); + for (ServerPlayer player : entity.level().getServer().getPlayerList().getPlayers()) { + if (player == entity || player.isAlliedTo(entity)) { + out.add(player); + } + } + return out; + } + + private static void broadcast(MinecraftServer server, Component message) { + server.getPlayerList().broadcastSystemMessage(message, false); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java new file mode 100644 index 0000000..e78278a --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/CasStrike.java @@ -0,0 +1,125 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.A10Entity; +import com.jnhprojects.mcheli.registry.ModEntities; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * A close air support run, {@code MCH_WeaponCAS}: three A-10 passes over a spot, 45 ticks apart, + * the second and third offset to either side. Vehicles with a CAS weapon drive theirs from their + * {@link WeaponSet}; the GLTD keeps one of these. + */ +public final class CasStrike { + /** Ticks from the call to the first pass: the time it takes the A-10 to arrive. */ + public static final int FIRST_PASS = 50; + private static final int BETWEEN_PASSES = 45; + private static final int PASSES = 3; + private static final RandomSource RANDOM = RandomSource.create(); + + private final String weapon; + private double x; + private double y; + private double z; + private int direction; + private @Nullable Entity shooter; + private @Nullable Entity user; + private int passes = PASSES; + private int ticksToNext; + + /** @param weapon the CAS weapon definition the A-10s fire, such as {@code a10gau8} */ + public CasStrike(String weapon) { + this.weapon = weapon; + } + + /** Whether passes are still to come. */ + public boolean isRunning() { + return passes < PASSES; + } + + /** + * Calls the strike on {@code at}. The A-10 comes in from one side of the caller's view, + * picked at random, never straight from behind or towards them. + */ + public void start(Vec3 at, float callerYaw, @Nullable Entity shooter, @Nullable Entity user) { + x = at.x; + y = at.y; + z = at.z; + direction = Math.floorMod(approach(callerYaw), 4); + this.shooter = shooter; + this.user = user; + passes = 0; + ticksToNext = FIRST_PASS; + } + + /** The original's approach: the quarter the caller faces, turned one way or the other. */ + static int approach(float callerYaw) { + int facing = (int) Mth.positiveModulo(callerYaw + 45.0F, 360.0F) / 90; + return facing + (RANDOM.nextBoolean() ? -1 : 1); + } + + public void tick(ServerLevel level, WeaponDefinition def) { + if (passes >= PASSES || --ticksToNext > 0) { + return; + } + Vec3 offset = passOffset(passes, direction); + spawnA10(level, weapon, def, direction, x + offset.x, y + 20.0, z + offset.z, shooter, user); + passes++; + ticksToNext = BETWEEN_PASSES; + } + + /** The first pass is dead on; the second strays to one side of the track, the third to the other. */ + static Vec3 passOffset(int pass, int direction) { + if (pass == 0) { + return Vec3.ZERO; + } + double sign = pass == 1 ? 1.0 : -1.0; + if (direction % 2 == 0) { + return new Vec3(RANDOM.nextDouble() * 10.0 * sign, 0.0, (RANDOM.nextDouble() - 0.5) * 10.0); + } + return new Vec3((RANDOM.nextDouble() - 0.5) * 10.0, 0.0, RANDOM.nextDouble() * 10.0 * sign); + } + + /** One A-10, starting 90 blocks short of the spot and flying over it at three blocks a tick. */ + static void spawnA10(ServerLevel level, String weapon, WeaponDefinition def, int direction, double x, double y, double z, + @Nullable Entity shooter, @Nullable Entity user) { + double mx = 0.0; + double mz = 0.0; + switch (direction) { + case 0 -> { + mz = 3.0; + z -= 90.0; + } + case 1 -> { + mx = -3.0; + x += 90.0; + } + case 2 -> { + mz = -3.0; + z += 90.0; + } + default -> { + mx = 3.0; + x -= 90.0; + } + } + A10Entity a10 = ModEntities.A10.get().create(level, EntitySpawnReason.TRIGGERED); + if (a10 == null) { + return; + } + a10.setPos(x, y, z); + a10.setup(weapon, direction, mx, mz, shooter, user); + a10.power = def.power; + a10.explosionPower = def.explosion; + a10.acceleration = def.acceleration; + level.addFreshEntity(a10); + ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java index 3cc80db..5fbad7b 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java @@ -8,7 +8,6 @@ import com.jnhprojects.mcheli.entity.HelicopterEntity; import com.jnhprojects.mcheli.entity.PlaneEntity; import com.jnhprojects.mcheli.entity.TankEntity; -import com.jnhprojects.mcheli.entity.projectile.A10Entity; import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; @@ -26,10 +25,7 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.tags.FluidTags; -import net.minecraft.util.Mth; import net.minecraft.util.RandomSource; -import net.minecraft.world.effect.MobEffectInstance; -import net.minecraft.world.effect.MobEffects; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.EntityType; @@ -42,6 +38,9 @@ import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; +import java.util.ArrayList; +import java.util.List; + /** * What each weapon type does when the server fires it: the {@code shot} methods of the * {@code MCH_Weapon*} classes. Every method returns whether a round was used. @@ -336,20 +335,24 @@ private static boolean targetingPod(ServerLevel level, WeaponSet set, Shot shot) WeaponDefinition w = set.definition(); Entity aimer = shot.aimer(); if ((w.target & WeaponDefinition.TARGET_BLOCK) != 0) { - sound(set, aimer, "ng"); + // {@code MCH_Multiplay.markPoint}: the block under the crew's view, 300 blocks out, goes to the team. + boolean marked = aimer instanceof ServerPlayer player && Spotting.markPoint(player, new Vec3(shot.x, shot.y, shot.z), 300.0); + sound(set, aimer, marked ? w.sound : "ng"); return true; } - boolean spotted = false; + List spotted = new ArrayList<>(); double range = Math.max(w.length, 1.0F); for (LivingEntity entity : level.getEntitiesOfClass(LivingEntity.class, aimer.getBoundingBox().inflate(range))) { if (entity == aimer || shot.shooter.hasPassenger(entity) || aimer.distanceToSqr(entity) > range * range || !matchesTarget(entity, w.target) || !Guidance.inCone(aimer, aimer.getYRot(), aimer.getXRot(), entity, w.angle)) { continue; } - entity.addEffect(new MobEffectInstance(MobEffects.GLOWING, Math.max(w.markTime, 20))); - spotted = true; + spotted.add(entity.getId()); + } + if (aimer instanceof ServerPlayer player) { + Spotting.tellTeam(player, spotted, Math.max(w.markTime, 20)); } - sound(set, shot.shooter, spotted ? w.sound : "ng"); + sound(set, shot.shooter, !spotted.isEmpty() ? w.sound : "ng"); return true; } @@ -384,12 +387,10 @@ private static boolean cas(ServerLevel level, WeaponSet set, Shot shot) { set.casX = at.x; set.casY = at.y; set.casZ = at.z; - int direction = (int) Mth.positiveModulo(shot.aimer().getYRot() + 45.0F, 360.0F) / 90; - direction += RANDOM.nextBoolean() ? -1 : 1; - set.casDirection = Math.floorMod(direction, 4); + set.casDirection = Math.floorMod(CasStrike.approach(shot.aimer().getYRot()), 4); set.casUser = shot.user; set.casShooter = shot.shooter; - set.casStartTick = 50; + set.casStartTick = CasStrike.FIRST_PASS; set.casCount = 0; if (shot.user instanceof ServerPlayer player) { ModSounds.playTo(player, set.definition().sound, 1.0F, 1.0F); @@ -403,55 +404,10 @@ static void tick(AircraftEntity vehicle, WeaponSet set) { || !(vehicle.level() instanceof ServerLevel level)) { return; } - double x = 0.0; - double z = 0.0; - if (set.casCount >= 1) { - double sign = set.casCount == 1 ? 1.0 : -1.0; - if (set.casDirection % 2 == 0) { - x = RANDOM.nextDouble() * 10.0 * sign; - z = (RANDOM.nextDouble() - 0.5) * 10.0; - } else { - z = RANDOM.nextDouble() * 10.0 * sign; - x = (RANDOM.nextDouble() - 0.5) * 10.0; - } - } - spawnA10(level, set, set.casX + x, set.casY + 20.0, set.casZ + z); + Vec3 offset = CasStrike.passOffset(set.casCount, set.casDirection); + CasStrike.spawnA10(level, set.name(), set.definition(), set.casDirection, set.casX + offset.x, set.casY + 20.0, + set.casZ + offset.z, set.casShooter, set.casUser); set.casStartTick = set.ticksSinceShot(0) + 45; set.casCount++; } - - private static void spawnA10(ServerLevel level, WeaponSet set, double x, double y, double z) { - double mx = 0.0; - double mz = 0.0; - switch (set.casDirection) { - case 0 -> { - mz = 3.0; - z -= 90.0; - } - case 1 -> { - mx = -3.0; - x += 90.0; - } - case 2 -> { - mz = -3.0; - z += 90.0; - } - default -> { - mx = 3.0; - x -= 90.0; - } - } - A10Entity a10 = ModEntities.A10.get().create(level, EntitySpawnReason.TRIGGERED); - if (a10 == null) { - return; - } - a10.setPos(x, y, z); - WeaponDefinition w = set.definition(); - a10.setup(set.name(), set.casDirection, mx, mz, set.casShooter, set.casUser); - a10.power = w.power; - a10.explosionPower = w.explosion; - a10.acceleration = w.acceleration; - level.addFreshEntity(a10); - ModSounds.playAt(level, x, y, z, "a-10_snd", 150.0F, 1.0F); - } } diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java new file mode 100644 index 0000000..b933115 --- /dev/null +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Spotting.java @@ -0,0 +1,158 @@ +package com.jnhprojects.mcheli.weapon; + +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.entity.HelicopterEntity; +import com.jnhprojects.mcheli.entity.PlaneEntity; +import com.jnhprojects.mcheli.network.MarkerPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.team.TeamManager; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.monster.Enemy; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.List; + +/** + * Spotting with the rangefinder, {@code MCH_Multiplay.spotEntity} and {@code markPoint}. + * + *

Spotted targets and marked points go to the spotter and their team only, as screen markers + * the HUD draws ({@code MCH_GuiTargetMarker}); nobody else sees them. + */ +public final class Spotting { + // The original's target filter bits. + public static final int OTHERS = 1; + public static final int MONSTERS = 2; + public static final int PLAYERS = 4; + public static final int GROUND_VEHICLES = 8; + public static final int HELICOPTERS = 16; + public static final int PLANES = 32; + + /** {@code RangeFinderSpotDist}. */ + public static final double RANGE = 400.0; + /** {@code RangeFinderSpotTime} in ticks, for players and vehicles. */ + private static final int PLAYER_TIME = 15 * 20; + /** Mobs stay marked for a minute. */ + private static final int MOB_TIME = 60 * 20; + /** Half the width of the cone a spot covers, in degrees: the original's angle times two. */ + private static final double CONE = 40.0; + + private Spotting() { + } + + static boolean matches(int filter, Entity entity) { + int bit; + if (entity instanceof PlaneEntity) { + bit = PLANES; + } else if (entity instanceof HelicopterEntity) { + bit = HELICOPTERS; + } else if (entity instanceof AircraftEntity) { + bit = GROUND_VEHICLES; + } else if (entity instanceof Player) { + bit = PLAYERS; + } else if (entity instanceof Enemy) { + bit = MONSTERS; + } else if (entity instanceof LivingEntity) { + bit = OTHERS; + } else { + return false; + } + return (filter & bit) != 0; + } + + /** + * Marks what {@code spotter} sees within {@link #RANGE} and the cone around their view: never + * a teammate, and only targets in plain sight. + * + * @return whether anything was spotted + */ + public static boolean spot(ServerPlayer spotter, int filter) { + ServerLevel level = spotter.level(); + Vec3 eye = spotter.getEyePosition(); + Vec3 look = spotter.getLookAngle(); + int ticks = (filter & ~(OTHERS | MONSTERS)) == 0 ? MOB_TIME : PLAYER_TIME; + List spotted = new ArrayList<>(); + for (Entity entity : level.getEntities(spotter, spotter.getBoundingBox().inflate(RANGE), e -> matches(filter, e))) { + if (isFriendly(spotter, entity)) { + continue; + } + Vec3 to = entity.getBoundingBox().getCenter().subtract(eye); + double dist = to.length(); + if (dist < 1.0 || dist > RANGE || Math.toDegrees(Math.acos(to.scale(1.0 / dist).dot(look))) > CONE) { + continue; + } + if (level.clip(new ClipContext(eye, entity.getBoundingBox().getCenter(), ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, spotter)).getType() == HitResult.Type.BLOCK) { + continue; + } + spotted.add(entity.getId()); + } + tellTeam(spotter, spotted, ticks); + return !spotted.isEmpty(); + } + + /** Sends the spotted entities to {@code spotter} and their team, {@code MCH_PacketNotifySpotedEntity}. */ + public static void tellTeam(ServerPlayer spotter, List entities, int ticks) { + if (entities.isEmpty()) { + return; + } + MarkerPacket packet = MarkerPacket.spotted(entities.stream().mapToInt(Integer::intValue).toArray(), ticks); + for (ServerPlayer player : TeamManager.teammates(spotter)) { + ModNetwork.sendToPlayer(player, packet); + } + } + + /** Teammates, and a vehicle crewed by one, are never spotted. */ + private static boolean isFriendly(ServerPlayer spotter, Entity entity) { + if (entity instanceof AircraftEntity vehicle) { + for (Entity crew : vehicle.getPassengers()) { + if (crew.isAlliedTo(spotter)) { + return true; + } + } + return false; + } + return entity.isAlliedTo(spotter); + } + + /** + * Tells the spotter and their team where they are looking, as block coordinates. + * + * @return whether the view ended on a block within range + */ + public static boolean markPoint(ServerPlayer spotter) { + return markPoint(spotter, spotter.getEyePosition(), RANGE); + } + + /** + * As above, looking from {@code from} along the spotter's view: a targeting pod marks from + * the vehicle, out to {@code range}. + */ + public static boolean markPoint(ServerPlayer spotter, Vec3 from, double range) { + ServerLevel level = spotter.level(); + BlockHitResult hit = level.clip(new ClipContext(from, from.add(spotter.getLookAngle().scale(range)), + ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, spotter)); + if (hit.getType() != HitResult.Type.BLOCK) { + return false; + } + BlockPos pos = hit.getBlockPos(); + MarkerPacket marker = MarkerPacket.point(pos); + Component message = Component.translatable("message.mcheli.mark_point", spotter.getDisplayName(), pos.getX(), pos.getY(), pos.getZ()); + for (ServerPlayer player : level.getServer().getPlayerList().getPlayers()) { + if (player == spotter || player.isAlliedTo(spotter)) { + player.sendSystemMessage(message); + ModNetwork.sendToPlayer(player, marker); + } + } + return true; + } +} diff --git a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java index b358a4c..2e37382 100644 --- a/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java +++ b/minecraft-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java @@ -6,6 +6,11 @@ import net.minecraft.util.Mth; import net.minecraft.util.RandomSource; import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; import org.jspecify.annotations.Nullable; import java.util.List; @@ -276,6 +281,57 @@ public void waitForGroup(boolean reload) { } } + /** + * {@code getLandInDistance}: how far out, measured flat, a shell fired now would come down, + * stepping its arc a tick at a time as the projectile does; -1 if it would not come down in + * range. Mortars show it on the HUD. + */ + public double landingDistance(Entity shooter) { + if (def.gravity >= 0.0F || mounts.isEmpty()) { + return -1.0; + } + WeaponMount mount = mounts.get(currentMount); + float yaw = Mth.wrapDegrees(shooter.getYRot() + rotationYaw + mount.yaw()); + float pitch = Mth.wrapDegrees(shooter.getXRot() + rotationPitch + mount.pitch()); + Vec3 dir = Vec3.directionFromRotation(pitch, yaw); + double acc = Math.min(def.acceleration, 4.0F); + double my = dir.y * def.acceleration; + if (my <= 0.0 || acc <= 0.0) { + return -1.0; + } + double mx = dir.x * def.acceleration; + double mz = dir.z * def.acceleration; + double gravity = def.gravity * (def.acceleration / acc); + double steps = my / def.gravity; + if (steps < -12.0) { + double f = steps / -12.0; + mx *= f; + my *= f; + mz *= f; + gravity *= f * f * 0.95; + } + Level level = shooter.level(); + double x = shooter.getX(); + double y = shooter.getY() + 3.0; + double z = shooter.getZ(); + for (int i = 0; i < 50; i++) { + Vec3 from = new Vec3(x, y, z); + Vec3 to = new Vec3(x + mx, y + my, z + mz); + BlockHitResult hit = level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, shooter)); + if (hit.getType() == HitResult.Type.BLOCK) { + return Math.hypot(hit.getBlockPos().getX() - shooter.getX(), hit.getBlockPos().getZ() - shooter.getZ()); + } + my += gravity; + x += mx; + y += my; + z += mz; + if (y < shooter.getY()) { + return Math.hypot(x - shooter.getX(), z - shooter.getZ()); + } + } + return -1.0; + } + // Firing /** diff --git a/minecraft-26.3/src/main/resources/assets/mcheli/blockstates/drafting_table.json b/minecraft-26.3/src/main/resources/assets/mcheli/blockstates/drafting_table.json new file mode 100644 index 0000000..82992af --- /dev/null +++ b/minecraft-26.3/src/main/resources/assets/mcheli/blockstates/drafting_table.json @@ -0,0 +1,5 @@ +{ + "multipart": [ + { "apply": { "model": "mcheli:block/drafting_table" } } + ] +} diff --git a/minecraft-26.3/src/main/resources/assets/mcheli/items/drafting_table.json b/minecraft-26.3/src/main/resources/assets/mcheli/items/drafting_table.json new file mode 100644 index 0000000..a3afc68 --- /dev/null +++ b/minecraft-26.3/src/main/resources/assets/mcheli/items/drafting_table.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "mcheli:block/drafting_table" + } +} diff --git a/minecraft-26.3/src/main/resources/assets/mcheli/models/block/drafting_table.json b/minecraft-26.3/src/main/resources/assets/mcheli/models/block/drafting_table.json new file mode 100644 index 0000000..8dfa5bc --- /dev/null +++ b/minecraft-26.3/src/main/resources/assets/mcheli/models/block/drafting_table.json @@ -0,0 +1,27 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/oak_planks", + "legs": "minecraft:block/iron_block", + "board": "minecraft:block/oak_planks", + "paper": "minecraft:block/white_concrete" + }, + "elements": [ + { "from": [1, 0, 2], "to": [2.5, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 2], "to": [15, 10, 3.5], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [1, 0, 12.5], "to": [2.5, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [13.5, 0, 12.5], "to": [15, 10, 14], "faces": { + "north": {"texture": "#legs"}, "south": {"texture": "#legs"}, "east": {"texture": "#legs"}, "west": {"texture": "#legs"}, "down": {"texture": "#legs"} } }, + { "from": [0, 10, 1], "to": [16, 11.5, 15], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { + "north": {"texture": "#board"}, "south": {"texture": "#board"}, "east": {"texture": "#board"}, "west": {"texture": "#board"}, + "up": {"texture": "#board"}, "down": {"texture": "#board"} } }, + { "from": [2, 11.5, 2.5], "to": [14, 11.6, 13.5], + "rotation": { "origin": [8, 10.5, 8], "axis": "x", "angle": -22.5 }, + "faces": { "up": {"texture": "#paper"} } } + ] +} diff --git a/minecraft-26.3/src/main/resources/assets/mcheli/particles/smoke.json b/minecraft-26.3/src/main/resources/assets/mcheli/particles/smoke.json new file mode 100644 index 0000000..977381b --- /dev/null +++ b/minecraft-26.3/src/main/resources/assets/mcheli/particles/smoke.json @@ -0,0 +1,16 @@ +{ + "textures": [ + "minecraft:big_smoke_0", + "minecraft:big_smoke_1", + "minecraft:big_smoke_2", + "minecraft:big_smoke_3", + "minecraft:big_smoke_4", + "minecraft:big_smoke_5", + "minecraft:big_smoke_6", + "minecraft:big_smoke_7", + "minecraft:big_smoke_8", + "minecraft:big_smoke_9", + "minecraft:big_smoke_10", + "minecraft:big_smoke_11" + ] +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json b/minecraft-26.3/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json new file mode 100644 index 0000000..610afa0 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/loot_table/blocks/drafting_table.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { "type": "minecraft:item", "name": "mcheli:drafting_table" } + ], + "conditions": [ + { "condition": "minecraft:survives_explosion" } + ] + } + ] +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/chain.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/chain.json new file mode 100644 index 0000000..484f546 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/chain.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "I": "#c:ingots/iron" + }, + "pattern": [ + "I I", + "III", + "I I" + ], + "result": { + "id": "mcheli:chain" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/container.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/container.json new file mode 100644 index 0000000..4c3bdcd --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/container.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "#c:chests/wooden", + "I": "#c:ingots/iron" + }, + "pattern": [ + "CCI" + ], + "result": { + "id": "mcheli:container" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/drafting_table.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/drafting_table.json new file mode 100644 index 0000000..f3d6324 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/drafting_table.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "C": "minecraft:crafting_table", + "F": "#minecraft:wooden_fences", + "P": "#minecraft:planks", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + "PCP", + "F F" + ], + "result": { + "id": "mcheli:drafting_table" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/fgm148.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fgm148.json new file mode 100644 index 0000000..c2c61fd --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fgm148.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "GR " + ], + "result": { + "id": "mcheli:fgm148" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/fgm148_bullet.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fgm148_bullet.json new file mode 100644 index 0000000..c87b480 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fgm148_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " R ", + " I ", + " G " + ], + "result": { + "count": 2, + "id": "mcheli:fgm148_bullet" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/fim92.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fim92.json new file mode 100644 index 0000000..33a068e --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fim92.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "G ", + "III", + "RI " + ], + "result": { + "id": "mcheli:fim92" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/fim92_bullet.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fim92_bullet.json new file mode 100644 index 0000000..c472fd6 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/fim92_bullet.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:gunpowders", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "R ", + " I ", + " G" + ], + "result": { + "count": 2, + "id": "mcheli:fim92_bullet" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/gltd.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/gltd.json new file mode 100644 index 0000000..104ac10 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/gltd.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "B": "#c:storage_blocks/iron", + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + " B ", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:gltd" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_green.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_green.json new file mode 100644 index 0000000..1046d9c --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_green.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:green_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_green", + "minecraft:item_model": "mcheli:m18_green", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_red.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_red.json new file mode 100644 index 0000000..f98ac51 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_red.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:red_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_red", + "minecraft:item_model": "mcheli:m18_red", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_violet.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_violet.json new file mode 100644 index 0000000..b05f48a --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_violet.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:purple_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_violet", + "minecraft:item_model": "mcheli:m18_violet", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_yellow.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_yellow.json new file mode 100644 index 0000000..57ecd20 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/m18_yellow.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "minecraft:yellow_dye", + "Y": "#c:ingots/iron" + }, + "pattern": [ + "DY" + ], + "result": { + "id": "mcheli:throwable", + "components": { + "mcheli:throwable": "m18_yellow", + "minecraft:item_model": "mcheli:m18_yellow", + "minecraft:max_stack_size": 1 + } + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/rangefinder.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/rangefinder.json new file mode 100644 index 0000000..58a1338 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/rangefinder.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "G": "#c:glass_blocks/colorless", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "III", + "RGR", + "III" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json new file mode 100644 index 0000000..d6bac29 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/rangefinder_recharge.json @@ -0,0 +1,11 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "equipment", + "ingredients": [ + "mcheli:rangefinder", + "#c:dusts/redstone" + ], + "result": { + "id": "mcheli:rangefinder" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/uav_station.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/uav_station.json new file mode 100644 index 0000000..24b97d5 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/uav_station.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:storage_blocks/redstone" + }, + "pattern": [ + "III", + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station" + } +} diff --git a/minecraft-26.3/src/main/resources/data/mcheli/recipe/uav_station2.json b/minecraft-26.3/src/main/resources/data/mcheli/recipe/uav_station2.json new file mode 100644 index 0000000..74bf0d2 --- /dev/null +++ b/minecraft-26.3/src/main/resources/data/mcheli/recipe/uav_station2.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "equipment", + "key": { + "D": "#c:gems/diamond", + "I": "#c:ingots/iron", + "R": "#c:dusts/redstone" + }, + "pattern": [ + "IDI", + "IRI" + ], + "result": { + "id": "mcheli:uav_station2" + } +}