diff --git a/CHANGELOG.md b/CHANGELOG.md index fadfca7..0e44cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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). +## [Unreleased] + +### Added + +- Night glow: rounds trail a tracer, rockets and missiles burn with a glowing motor, burning + flares light up around them, and every explosion throws a flash of light and glowing embers. + A crashing vehicle goes up in a bigger flash and keeps burning with a glow for several seconds. + The glow is faint by day and strongest at night, in caves and under water. Turn it off with + `effects.nightGlow` in the new client config, `config/mcheli-client.toml`. +- Wrecks stay for 30 seconds instead of 25 and keep burning until they disappear: flames, a fire + glow and rising embers that die down over the last five seconds. Sunk wrecks do not burn. +- Screenshots of night glow in `docs/images/phase-6`, shown in the README. + +### Changed + +- Vehicles no longer flash red when hit. +- Hits no longer jolt a vehicle that someone is flying or driving, and explosions no longer push + it. An empty vehicle still rocks a little from a nearby blast (a quarter of the old push). + ## [1.1.0-beta.1] - 2026-10-29 Phase 5 of the road to 1.1.0: the groundwork that content packs, older Minecraft versions and diff --git a/README.md b/README.md index bd38278..82ab940 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,18 @@ Phase 4 clips (click a picture to open the video): ## Screenshots +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. @@ -224,6 +236,12 @@ server runs): | `vehicles.disabled` | `[]` | Vehicles that cannot be placed, by name, such as `["ah-64", "f-35b"]` | | `weapons.infiniteAmmo` | `false` | Vehicle weapons never run out of ammunition | +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 | + ## Documentation * [Changelog](CHANGELOG.md) diff --git a/common/src/main/java/com/jnhprojects/mcheli/effect/NightGlow.java b/common/src/main/java/com/jnhprojects/mcheli/effect/NightGlow.java new file mode 100644 index 0000000..87e2579 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/effect/NightGlow.java @@ -0,0 +1,30 @@ +package com.jnhprojects.mcheli.effect; + +/** + * Not in the original: how strongly tracers, rocket motors and fireballs glow, from the light + * around them. In daylight the glow is a faint shimmer on top of the model; at night, in a cave or + * under water it is what you see from far away. Light levels are Minecraft's 0 to 15 scale. + */ +public final class NightGlow { + /** The glow left at full daylight, so a tracer still catches the eye against a dark hillside. */ + public static final float DAYLIGHT = 0.25F; + + private static final int MAX_LIGHT = 15; + + private NightGlow() { + } + + /** + * @param blockLight light from torches, lava and the like at the glowing thing + * @param skyLight sky light at the same spot, before the time of day is applied + * @param skyDarken how far the sky has dimmed: 0 at noon, 11 at midnight, more in a storm + * @return the glow strength, {@link #DAYLIGHT} in full light up to 1 in darkness + */ + public static float strength(int blockLight, int skyLight, int skyDarken) { + int ambient = Math.clamp(Math.max(blockLight, skyLight - skyDarken), 0, MAX_LIGHT); + float dark = 1.0F - (float) ambient / MAX_LIGHT; + // Smoothstep, so dusk fades the glow in rather than switching it on at one light level. + float eased = dark * dark * (3.0F - 2.0F * dark); + return DAYLIGHT + (1.0F - DAYLIGHT) * eased; + } +} diff --git a/common/src/test/java/com/jnhprojects/mcheli/effect/NightGlowTest.java b/common/src/test/java/com/jnhprojects/mcheli/effect/NightGlowTest.java new file mode 100644 index 0000000..2d31eec --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/effect/NightGlowTest.java @@ -0,0 +1,48 @@ +package com.jnhprojects.mcheli.effect; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NightGlowTest { + private static final float EPSILON = 1.0E-6F; + + @Test + void middayLeavesOnlyTheDaylightShimmer() { + assertEquals(NightGlow.DAYLIGHT, NightGlow.strength(0, 15, 0), EPSILON); + } + + @Test + void pitchDarkGlowsInFull() { + assertEquals(1.0F, NightGlow.strength(0, 0, 0), EPSILON); + } + + @Test + void midnightGlowsFarMoreThanNoon() { + float midnight = NightGlow.strength(0, 15, 11); + assertTrue(midnight > 0.8F, "midnight glow " + midnight); + } + + @Test + void aTorchNearbyDimsTheGlowAtNight() { + assertTrue(NightGlow.strength(14, 15, 11) < NightGlow.strength(0, 15, 11)); + } + + @Test + void dimmingIsGradualAndNeverGoesBackUp() { + float previous = 0.0F; + for (int darken = 0; darken <= 15; darken++) { + float s = NightGlow.strength(0, 15, darken); + assertTrue(s >= previous, "glow dropped at sky darken " + darken); + assertTrue(s - previous < 0.2F || darken == 0, "glow jumped at sky darken " + darken); + previous = s; + } + } + + @Test + void outOfRangeLightIsClamped() { + assertEquals(NightGlow.DAYLIGHT, NightGlow.strength(40, 40, 0), EPSILON); + assertEquals(1.0F, NightGlow.strength(-3, 0, 20), EPSILON); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57f06ca..aed482e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,14 +23,16 @@ This document explains how MCHeli Rework is organized and why. It reflects the c │ entity AircraftEntity and one subclass per kind │ (forge-26.3) │ item AircraftItem, VehicleIngredient │ │ network PilotStatePacket, CrewActionPacket, ... │ Minecraft + Forge - │ registry entities, items, components, creative tabs │ - │ (root) MCHeli, ServerConfig, McheliCommand, reload │ + │ particle GlowParticleOptions │ + │ registry entities, items, components, particles, tabs │ + │ (root) MCHeli, configs, McheliCommand, reload │ ├────────────────────────────────────────────────────────────────┤ │ content Definition, DefinitionReader, ContentRegistry, │ common module │ aircraft/* definitions, recipe conversion │ │ model Mesh, MqoLoader, ObjLoader │ pure Java │ hud HudScript, Expression, HudVariable │ │ physics AircraftRotation, GroundImpact │ + │ effect NightGlow │ └────────────────────────────────────────────────────────────────┘ ``` @@ -55,8 +57,10 @@ MCHeli constructor ├─ LegacyContent.registerPacks that folder as a required resource + data pack ├─ ContentRegistry.load(mcheli/imported/assets/mcheli) parse helicopters, planes, tanks, vehicles ├─ registerConfig(SERVER, ServerConfig.SPEC) serverconfig/mcheli-server.toml + ├─ registerConfig(CLIENT, ClientConfig.SPEC) config/mcheli-client.toml ├─ ModDataComponents.register mcheli:vehicle ├─ ModEntities.register one entity type per kind + ├─ ModParticles.register mcheli:glow ├─ ModItems.register(content) one item per kind, plus the 1.0.0 items ├─ ModCreativeTabs.register ├─ ModRecipes.register mcheli:vehicle ingredient, recipe book @@ -309,7 +313,15 @@ only the two option values (a locked entity id and a fire mode) and repeats ever Damage enters through `AircraftEntity.hurtServer`, a port of `attackEntityFrom`. Projectile rays are clipped against the main hit box and every `BoundingBox` (`clipHitBoxes`); the box struck first lends its damage factor to the armour calculation. A vehicle at zero hit points is destroyed: -it burns, spins down or falls, explodes on impact and despawns after 500 ticks. +it burns, spins down or falls, explodes on impact and despawns after 600 ticks (30 seconds; the +original used 500). Until then each client keeps the wreck burning (`wreckFire`): flames, a +flickering `mcheli:glow` and embers, dying down over the last 100 ticks it has seen, and nothing +under water. + +A hit neither tints the vehicle red nor calls `markHurt`: the vehicle a player drives is simulated +on their client, and resending the server's motion on every hit snapped it back a step. For the +same reason `Explosions` does not push a driven vehicle, and pushes an empty one at a quarter of +the mob knockback. Impacts are measured from the position each tick, so they work whichever side simulates the vehicle. Besides the original's tilt and submersion damage, flying into terrain or another vehicle @@ -355,6 +367,16 @@ set the packet's `crash` flag for a larger show of our own: the same smoke shell above the impact, a cluster of fireballs, a climbing plume of signal smoke and burning fuel, capped at size 12 to keep the particle count in check. +**Night glow** is our own. `Glow` holds two additive pipelines that test depth but never write it +(the lightning blend, so overlapping glows add up and never cut holes in the smoke behind them), +falling back to the vanilla translucent OIT pipelines when Fabulous graphics are on. It draws +camera facing halos and tracer ribbons for `ProjectileRenderer` (rounds and rocket motors) and +`FlareRenderer` (burning flares, including crash debris), and is the layer of the `mcheli:glow` +particle that `ExplosionEffect` spawns for the flash, the embers and the lingering crash fire. +How bright a glow gets comes from `effect.NightGlow` in `common`: the block light, sky light and +the sky darkening at that spot, so it is faint at noon and full at night, in caves and under +water. `ClientConfig`, a Forge `CLIENT` config, turns it off per player. + **CrewController** also hides the first person hand through `RenderHandEvent` wherever the original did: for the pilot, and for anyone looking through a camera or with a weapon selected. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3421dcd..36fa136 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -118,7 +118,8 @@ releases page. See [the release channels](ROADMAP.md#release-channels). Neither the repository nor the jar may contain EMB4's files. Don't commit anything from the zip or from `run/mcheli/imported`. As a guard, the `jar` task excludes everything under -`assets/mcheli` except `lang` and the four fallback item models of the generic vehicle items. +`assets/mcheli` except `lang`, the four fallback item models of the generic vehicle items and +the night glow particle (`particles/glow.json`, `textures/particle/glow.png`, both our own). After a build, check every jar with: ``` @@ -126,7 +127,7 @@ for jar in build/release/*.jar; do unzip -l "$jar" | grep -v "com/"; done ``` which should list only the manifest, `mods.toml`, `pack.mcmeta`, the two lang files, the four -generic item models and the damage type data. +generic item models, the glow particle and its texture, and the damage type data. When the conversion changes, change `content.LegacyImporter` and bump its `FORMAT`, so existing installs, the dev run and the tests all convert again. Keys the rework adds go into the lang files diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ffcee20..3103af3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -181,6 +181,8 @@ The external content packs from phase 4, in the package format the workshop will - [ ] Server limits and an allow list in the config; clients cap the cache size - [ ] Pack author guide in `docs/content/packs.md`, with a small example pack in the repo made from our own assets (no EMB4 files) +- [x] Night glow: tracers, rocket motors, flares and explosions glow, faint by day and bright at + night; a crash keeps burning with a glow and the wreck burns for 30 seconds. Client config `effects.nightGlow` turns it off. - [ ] Crash spin for shot down aircraft: planes and jets roll into a nose-down spiral instead of just falling, and helicopters spin faster with some wobble. The spin direction comes from the entity id so every client sees the same thing. diff --git a/docs/TESTING.md b/docs/TESTING.md index 62607ef..a613ca5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -305,6 +305,36 @@ Keep a copy of a 1.0.0 world with vehicles parked in it, items in the hotbar and - [ ] `./gradlew build` produces `build/release/mcheli-26.3-1.1.0-beta.1.jar`; it holds no EMB4 file (DEVELOPMENT.md, "Original content"). +## Phase 6 checklist (`1.1.0-beta.2`) + +Run each section in single player and again on a dedicated server, unless it says otherwise. + +### Night glow + +Set the time with `/time set midnight` and compare with `/time set noon`. + +- [ ] Machine gun rounds trail a warm tracer at night; at noon only a faint shimmer shows. +- [ ] Rockets and missiles show a glowing motor behind them for their whole flight. +- [ ] A flare salvo lights up around each flare; smoke grenades do not glow. +- [ ] A rocket hit gives a short bright flash and a few embers; the smoke behind stays visible + through the flash (no dark square or cut out). +- [ ] Shooting down a helicopter: a big flash when it hits the ground, then the wreck glows for + several seconds and the embers thrown out glow as they fall. +- [ ] The wreck keeps burning (flames, glow, rising embers) for 30 seconds, dies down over the + last five and then disappears. A boat wreck that sank does not glow under water. +- [ ] In a cave or under a roof at noon, the glow is as bright as at night. +- [ ] Nothing glows under water (torpedoes, rounds fired into water). +- [ ] `effects.nightGlow = false` in `config/mcheli-client.toml`: no glow at all, the rest of the + effects unchanged. Another player on the same server still sees the glow. +- [ ] Video settings, graphics set to Fabulous: the glow still draws and nothing crashes. + +### Vehicle hits + +- [ ] A vehicle hit by rounds or a rocket no longer flashes red. +- [ ] Flying a plane or helicopter while another player (or a turret) shoots it: no jolt or + stutter on each hit; the HP still goes down. Same driving a tank. +- [ ] A rocket exploding next to an empty parked vehicle rocks it slightly, much less than before. + ## 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-6/burning-debris-dusk.png b/docs/images/phase-6/burning-debris-dusk.png new file mode 100644 index 0000000..b372774 Binary files /dev/null and b/docs/images/phase-6/burning-debris-dusk.png differ diff --git a/docs/images/phase-6/crash-embers-night.png b/docs/images/phase-6/crash-embers-night.png new file mode 100644 index 0000000..5fdfb7b Binary files /dev/null and b/docs/images/phase-6/crash-embers-night.png differ diff --git a/docs/images/phase-6/helicopter-crash-night.png b/docs/images/phase-6/helicopter-crash-night.png new file mode 100644 index 0000000..6f406a8 Binary files /dev/null and b/docs/images/phase-6/helicopter-crash-night.png differ diff --git a/docs/images/phase-6/patrol-boat-cook-off-dusk.png b/docs/images/phase-6/patrol-boat-cook-off-dusk.png new file mode 100644 index 0000000..4b98a12 Binary files /dev/null and b/docs/images/phase-6/patrol-boat-cook-off-dusk.png differ diff --git a/docs/images/phase-6/sa-2-night-intercept.png b/docs/images/phase-6/sa-2-night-intercept.png new file mode 100644 index 0000000..952ae6d Binary files /dev/null and b/docs/images/phase-6/sa-2-night-intercept.png differ diff --git a/docs/images/phase-6/torpedo-run-dusk.png b/docs/images/phase-6/torpedo-run-dusk.png new file mode 100644 index 0000000..6241b35 Binary files /dev/null and b/docs/images/phase-6/torpedo-run-dusk.png differ diff --git a/forge-26.3/build.gradle b/forge-26.3/build.gradle index 8c43f64..c312562 100644 --- a/forge-26.3/build.gradle +++ b/forge-26.3/build.gradle @@ -81,7 +81,9 @@ tasks.named('jar', Jar) { // zip (LegacyContent). The repository no longer holds it, but the jar is packed from // build/sourceSets/main, which can still have copies from older builds and from the IDE. def shipped = ['assets/mcheli/items/helicopter.json', 'assets/mcheli/items/plane.json', - 'assets/mcheli/items/tank.json', 'assets/mcheli/items/vehicle.json'] + 'assets/mcheli/items/tank.json', 'assets/mcheli/items/vehicle.json', + // Our own: the night glow sprite (generated, no EMB4 content) and its particle definition. + 'assets/mcheli/particles/glow.json', 'assets/mcheli/textures/particle/glow.png'] exclude { FileTreeElement element -> String path = element.relativePath.pathString !element.directory && path.startsWith('assets/mcheli/') && !path.startsWith('assets/mcheli/lang/') && diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java new file mode 100644 index 0000000..c829143 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli; + +import net.minecraftforge.common.ForgeConfigSpec; + +/** + * Per player look and feel, in {@code config/mcheli-client.toml}. Nothing here changes how the + * game plays, so each player picks for themselves and the server never sees it. + */ +public final class ClientConfig { + public static final ForgeConfigSpec SPEC; + + private static final ForgeConfigSpec.BooleanValue NIGHT_GLOW; + + static { + ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + + builder.push("effects"); + NIGHT_GLOW = builder + .comment("Whether tracers, rocket motors, flares and explosions glow. The glow is faint by day", + "and strongest at night, in caves and under water.") + .define("nightGlow", true); + builder.pop(); + + SPEC = builder.build(); + } + + private ClientConfig() { + } + + public static boolean nightGlow() { + return SPEC.isLoaded() ? NIGHT_GLOW.get() : NIGHT_GLOW.getDefault(); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java index fd1a754..abb83db 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -7,6 +7,7 @@ import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; import com.jnhprojects.mcheli.registry.ModItems; +import com.jnhprojects.mcheli.registry.ModParticles; import com.jnhprojects.mcheli.registry.ModRecipes; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.eventbus.api.bus.BusGroup; @@ -37,8 +38,10 @@ public MCHeli(FMLJavaModLoadingContext context) { legacyContent.registerPacks(); content = loadContent(legacyContent.assets()); context.registerConfig(ModConfig.Type.SERVER, ServerConfig.SPEC); + context.registerConfig(ModConfig.Type.CLIENT, ClientConfig.SPEC); ModDataComponents.register(modBus); ModEntities.register(modBus); + ModParticles.register(modBus); ModItems.register(modBus, content); ModCreativeTabs.register(modBus); ModRecipes.register(modBus); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java index 24c3ea7..3d11436 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java @@ -3,20 +3,23 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.client.hud.HudRenderer; import com.jnhprojects.mcheli.client.hud.HudScripts; +import com.jnhprojects.mcheli.client.particle.GlowParticle; import com.jnhprojects.mcheli.client.render.A10Renderer; import com.jnhprojects.mcheli.client.render.AircraftRenderer; +import com.jnhprojects.mcheli.client.render.FlareRenderer; import com.jnhprojects.mcheli.client.render.MeshCache; import com.jnhprojects.mcheli.client.render.ProjectileRenderer; import com.jnhprojects.mcheli.client.render.RiderPose; import com.jnhprojects.mcheli.client.sound.EngineSoundPlayer; import com.jnhprojects.mcheli.entity.ClientAccess; import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModParticles; import net.minecraft.client.Minecraft; -import net.minecraft.client.renderer.entity.NoopRenderer; import net.minecraftforge.client.event.ClientPlayerNetworkEvent; import net.minecraftforge.client.event.EntityRenderersEvent; import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; import net.minecraftforge.client.event.RegisterKeyMappingsEvent; +import net.minecraftforge.client.event.RegisterParticleProvidersEvent; import net.minecraftforge.eventbus.api.bus.BusGroup; /** Client only wiring. Kept out of {@code MCHeli} so dedicated servers never load these classes. */ @@ -41,9 +44,10 @@ public static void init(BusGroup modBus) { event.registerEntityRenderer(ModEntities.TORPEDO.get(), ProjectileRenderer::new); event.registerEntityRenderer(ModEntities.MARKER_ROCKET.get(), ProjectileRenderer::new); event.registerEntityRenderer(ModEntities.DISPENSED_ITEM.get(), ProjectileRenderer::new); - event.registerEntityRenderer(ModEntities.FLARE.get(), NoopRenderer::new); + event.registerEntityRenderer(ModEntities.FLARE.get(), FlareRenderer::new); event.registerEntityRenderer(ModEntities.A10.get(), A10Renderer::new); }); + RegisterParticleProvidersEvent.BUS.addListener(event -> event.registerSpriteSet(ModParticles.GLOW.get(), GlowParticle.Provider::new)); RegisterKeyMappingsEvent.BUS.addListener(event -> KeyBindings.ALL.forEach(event::register)); RegisterClientReloadListenersEvent.BUS.addListener(event -> { event.registerReloadListener(MeshCache.INSTANCE); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java new file mode 100644 index 0000000..a09d9b2 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/particle/GlowParticle.java @@ -0,0 +1,103 @@ +package com.jnhprojects.mcheli.client.particle; + +import com.jnhprojects.mcheli.client.render.Glow; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import net.minecraft.client.Camera; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleProvider; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.particle.SpriteSet; +import net.minecraft.client.renderer.state.level.QuadParticleRenderState; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import org.jspecify.annotations.Nullable; + +/** + * {@code mcheli:glow}: a ball of light added on top of the scene. A flash swells quickly and fades + * out; a fire holds its brightness, wavering, and only fades towards the end. How bright it gets + * depends on the light where it starts, so the same explosion is a flicker at noon and lights up + * the sky at night. + */ +public class GlowParticle extends SingleQuadParticle { + private final float size; + private final float peak; + private final boolean flicker; + private float waver = 1.0F; + + GlowParticle(ClientLevel level, double x, double y, double z, double xd, double yd, double zd, GlowParticleOptions options, + TextureAtlasSprite sprite, float strength) { + super(level, x, y, z, sprite); + setParticleSpeed(xd, yd, zd); + this.size = options.size(); + this.peak = strength; + this.flicker = options.flicker(); + this.quadSize = size; + this.lifetime = options.lifetime(); + this.friction = 0.9F; + this.gravity = 0.0F; + this.hasPhysics = false; + setColor(ARGB.redFloat(options.color()), ARGB.greenFloat(options.color()), ARGB.blueFloat(options.color())); + this.alpha = 0.0F; + } + + @Override + public void tick() { + super.tick(); + if (flicker) { + waver = 0.7F + random.nextFloat() * 0.3F; + } + } + + @Override + public void extract(QuadParticleRenderState state, Camera camera, float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + if (flicker) { + // Burning: hold, then die down over the last third. + alpha = peak * waver * Math.min(1.0F, (1.0F - t) * 3.0F); + } else { + // A flash: brightest at once, gone quickly. + float left = 1.0F - t; + alpha = peak * left * left; + } + super.extract(state, camera, partialTicks); + } + + @Override + public float getQuadSize(float partialTicks) { + float t = Mth.clamp((age + partialTicks) / lifetime, 0.0F, 1.0F); + return flicker ? size * (0.85F + 0.15F * waver) : size * (0.6F + 0.4F * Math.min(1.0F, t * 4.0F)); + } + + @Override + protected int getLightCoords(float partialTicks) { + return LightCoordsUtil.FULL_BRIGHT; + } + + @Override + protected Layer getLayer() { + return Glow.PARTICLE_LAYER; + } + + public static class Provider implements ParticleProvider { + private final SpriteSet sprites; + + public Provider(SpriteSet sprites) { + this.sprites = sprites; + } + + @Override + public @Nullable Particle createParticle(GlowParticleOptions options, ClientLevel level, double x, double y, double z, + double xd, double yd, double zd, RandomSource random) { + float strength = Glow.strength(level, BlockPos.containing(x, y, z)); + if (strength <= 0.0F) { + return null; + } + return new GlowParticle(level, x, y, z, xd, yd, zd, options, sprites.first(), strength); + } + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java index b1e0f1d..07775ee 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java @@ -15,7 +15,6 @@ public class AircraftRenderState extends EntityRenderState { public float yaw; public float pitch; public float roll; - public float hurtTime; /** Helicopter main rotor or plane propeller angle. */ public float rotorAngle; public float throttle; diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java index 3f51aa5..8fc8cb0 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -21,6 +21,7 @@ import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.client.renderer.rendertype.RenderTypes; import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.resources.Identifier; import net.minecraft.util.LightCoordsUtil; import net.minecraft.util.Mth; @@ -52,7 +53,6 @@ public void extractRenderState(T entity, AircraftRenderState state, float partia state.yaw = entity.getYRot(partialTicks); state.pitch = entity.getXRot(partialTicks); state.roll = entity.roll(partialTicks); - state.hurtTime = entity.getHurtTime() - partialTicks; if (def == null) { return; } @@ -209,7 +209,7 @@ public void submit(AircraftRenderState state, PoseStack poseStack, SubmitNodeCol poseStack.rotateDegrees(Axis.XP, state.pitch); poseStack.rotateDegrees(Axis.ZP, state.roll); new PartRenderer(def, mesh, state, poseStack, collector, texture(def.folder(), state.texture), state.lightCoords, - MeshRenderer.overlay(state.hurtTime), state.glassColor).submitAll(); + OverlayTexture.NO_OVERLAY, state.glassColor).submitAll(); poseStack.popPose(); } super.submit(state, poseStack, collector, camera); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java new file mode 100644 index 0000000..66cffb4 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/FlareRenderer.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.entity.projectile.FlareEntity; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.world.phys.Vec3; + +/** + * Flares and the burning debris of a crash were only their particle trails. Not in the original: + * a burning flare now glows, which is what makes a flare salvo light up the night sky. Smoke + * grenades stay invisible. + */ +public class FlareRenderer extends EntityRenderer { + private static final float RADIUS = 1.4F; + + public FlareRenderer(EntityRendererProvider.Context context) { + super(context); + this.shadowRadius = 0.0F; + } + + public static class State extends EntityRenderState { + float glow; + float flicker = 1.0F; + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(FlareEntity entity, State state, float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.glow = entity.isSmoke() || entity.isInWater() ? 0.0F : Glow.strength(entity.level(), entity.blockPosition()); + // A cheap waver keyed to the entity, so neighbouring flares do not pulse in step. + state.flicker = 0.8F + 0.2F * (float) Math.sin((entity.tickCount + partialTicks) * 1.7F + entity.getId()); + } + + @Override + public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * state.flicker, GlowParticleOptions.FIRE, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, RADIUS * 0.35F, GlowParticleOptions.FLASH, state.glow); + super.submit(state, pose, collector, camera); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java new file mode 100644 index 0000000..404630a --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/Glow.java @@ -0,0 +1,157 @@ +package com.jnhprojects.mcheli.client.render; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.effect.NightGlow; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.renderpearl.api.pipeline.BlendFunction; +import com.mojang.renderpearl.api.pipeline.ColorTargetState; +import com.mojang.renderpearl.api.pipeline.CompareOp; +import com.mojang.renderpearl.api.pipeline.DepthStencilState; +import com.mojang.renderpearl.api.pipeline.RenderPipeline; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; + +/** + * Not in the original: light that adds to the scene instead of covering it, for tracers, rocket + * motors, flares and fireballs. Everything here blends additively and leaves the depth buffer + * alone, so overlapping glows brighten each other and never cut holes in the smoke behind them. + * With order independent transparency on, the vanilla translucent pipelines stand in. + */ +public final class Glow { + /** A white disc fading to nothing at the rim; also the {@code mcheli:glow} particle sprite. */ + public static final Identifier TEXTURE = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/particle/glow.png"); + + private static final DepthStencilState TEST_ONLY = new DepthStencilState(CompareOp.GREATER_THAN_OR_EQUAL, false); + + private static final RenderPipeline PARTICLE_PIPELINE = RenderPipeline.builder(RenderPipelines.PARTICLE_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "pipeline/glow_particle")) + .withColorTargetState(new ColorTargetState(BlendFunction.LIGHTNING)) + .withDepthStencilState(TEST_ONLY) + .build(); + + 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) + .build(); + + /** The particle layer for {@code mcheli:glow}. */ + @SuppressWarnings("deprecation") // Vanilla's own particle layers still name the atlas this way. + public static final SingleQuadParticle.Layer PARTICLE_LAYER = new SingleQuadParticle.Layer( + true, TextureAtlas.LOCATION_PARTICLES, PARTICLE_PIPELINE, 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) + .sortOnUpload() + .createRenderSetup()); + + private static final int FULL_BRIGHT = LightCoordsUtil.FULL_BRIGHT; + + private Glow() { + } + + /** + * How strongly something at {@code pos} glows: faint by day, full at night, in caves and + * under water. 0 when the player turned the glow off. + */ + public static float strength(Level level, BlockPos pos) { + if (!ClientConfig.nightGlow()) { + return 0.0F; + } + return NightGlow.strength(level.getBrightness(LightLayer.BLOCK, pos), level.getBrightness(LightLayer.SKY, pos), level.getSkyDarken()); + } + + /** + * A disc of light facing the camera, centred {@code offset} away from the current pose. + * + * @param color RGB; {@code strength} becomes the alpha, which scales the light added + */ + public static void halo(PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera, Vec3 offset, float radius, int color, + float strength) { + if (strength <= 0.0F || radius <= 0.0F) { + return; + } + int argb = ARGB.color(alpha(strength), color); + pose.pushPose(); + pose.translate(offset); + pose.rotate(camera.orientation); + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // Both windings, so culling never hides it whichever way the quad ends up facing. + vertex(buffer, p, -radius, -radius, 0.0F, 0.0F, 1.0F, argb); + vertex(buffer, p, radius, -radius, 0.0F, 1.0F, 1.0F, argb); + vertex(buffer, p, radius, radius, 0.0F, 1.0F, 0.0F, argb); + vertex(buffer, p, -radius, radius, 0.0F, 0.0F, 0.0F, argb); + vertex(buffer, p, -radius, radius, 0.0F, 0.0F, 0.0F, argb); + vertex(buffer, p, radius, radius, 0.0F, 1.0F, 0.0F, argb); + vertex(buffer, p, radius, -radius, 0.0F, 1.0F, 1.0F, argb); + vertex(buffer, p, -radius, -radius, 0.0F, 0.0F, 1.0F, argb); + }); + pose.popPose(); + } + + /** + * A tracer: a ribbon of light from the current pose back along {@code tail}, turned to face + * the camera, bright at the head and fading out towards the end. + * + * @param toCamera from the camera to the head, so the ribbon can be turned side on to the view + */ + public static void streak(PoseStack pose, SubmitNodeCollector collector, Vec3 toCamera, Vec3 tail, float width, int color, float strength) { + if (strength <= 0.0F || tail.lengthSqr() < 1.0E-4) { + return; + } + Vec3 side = tail.cross(toCamera); + if (side.lengthSqr() < 1.0E-8) { + // Seen exactly end on; the halo at the head covers this. + return; + } + side = side.normalize().scale(width); + int head = ARGB.color(alpha(strength), color); + int end = ARGB.color(0, color); + float sx = (float) side.x; + float sy = (float) side.y; + float sz = (float) side.z; + float tx = (float) tail.x; + float ty = (float) tail.y; + float tz = (float) tail.z; + collector.submitCustomGeometry(pose, HALO, (p, buffer) -> { + // The middle row of the disc texture gives the ribbon soft edges. + vertex(buffer, p, sx, sy, sz, 0.0F, 0.5F, head); + vertex(buffer, p, -sx, -sy, -sz, 1.0F, 0.5F, head); + vertex(buffer, p, tx - sx, ty - sy, tz - sz, 1.0F, 0.5F, end); + vertex(buffer, p, tx + sx, ty + sy, tz + sz, 0.0F, 0.5F, end); + vertex(buffer, p, tx + sx, ty + sy, tz + sz, 0.0F, 0.5F, end); + vertex(buffer, p, tx - sx, ty - sy, tz - sz, 1.0F, 0.5F, end); + vertex(buffer, p, -sx, -sy, -sz, 1.0F, 0.5F, head); + vertex(buffer, p, sx, sy, sz, 0.0F, 0.5F, head); + }); + } + + private static int alpha(float strength) { + return Math.clamp(Math.round(strength * 255.0F), 0, 255); + } + + private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float x, float y, float z, float u, float v, int argb) { + buffer.addVertex(pose, x, y, z) + .setColor(argb) + .setUv(u, v) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(FULL_BRIGHT) + .setNormal(pose, 0.0F, 1.0F, 0.0F); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java index 77e9d3d..47cc66e 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java @@ -3,7 +3,6 @@ import com.jnhprojects.mcheli.model.MeshGroup; import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.VertexConsumer; -import net.minecraft.client.renderer.texture.OverlayTexture; import org.joml.Matrix4f; import org.joml.Vector3f; @@ -86,8 +85,4 @@ private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float[] p .setLight(light) .setNormal(pose, n[a], n[a + 1], n[a + 2]); } - - static int overlay(float hurtTime) { - return OverlayTexture.pack(0, OverlayTexture.v(hurtTime > 0.0F)); - } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java index 625040a..42514b8 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java @@ -3,9 +3,17 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.weapon.WeaponColor; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.projectile.AAMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ASMissileEntity; +import com.jnhprojects.mcheli.entity.projectile.ATMissileEntity; import com.jnhprojects.mcheli.entity.projectile.BombEntity; +import com.jnhprojects.mcheli.entity.projectile.BulletEntity; +import com.jnhprojects.mcheli.entity.projectile.MarkerRocketEntity; import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; +import com.jnhprojects.mcheli.entity.projectile.RocketEntity; +import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.math.Axis; import net.minecraft.client.renderer.SubmitNodeCollector; @@ -19,26 +27,48 @@ import net.minecraft.resources.Identifier; import net.minecraft.tags.FluidTags; import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; /** * Draws rounds, rockets, missiles and bombs from {@code models/bullets}, tinted with the weapon's * {@code BulletColor} (its in-water colour below the surface) and always fully lit, as the * original's {@code MCH_RenderBulletBase} did. + * + *

Not in the original: rounds trail a tracer and rocket motors burn with a glow, faint by day + * and bright at night, so a firefight in the dark can be followed by eye. Nothing glows under + * water. */ public class ProjectileRenderer extends EntityRenderer { private static final int FULL_BRIGHT = 0xF000F0; + /** Warm tracer colour for rounds whose weapon keeps the default white tint. */ + private static final int TRACER = 0xFFC46A; + /** The longest tracer ribbon, in blocks; fast rounds cover far more than this per tick. */ + private static final double MAX_TRACER = 6.0; + /** How far behind the projectile's centre the motor glow sits, in blocks. */ + private static final double MOTOR_OFFSET = 0.6; public ProjectileRenderer(EntityRendererProvider.Context context) { super(context); this.shadowRadius = 0.0F; } + enum GlowKind { + NONE, + TRACER, + MOTOR + } + public static class State extends EntityRenderState { String model = ""; float yaw; float pitch; int color = 0xFFFFFFFF; boolean visible = true; + GlowKind glowKind = GlowKind.NONE; + float glow; + int glowColor = TRACER; + /** Distance covered over the last tick, which points the tracer and the motor glow. */ + Vec3 travel = Vec3.ZERO; } @Override @@ -54,8 +84,33 @@ public void extractRenderState(ProjectileEntity entity, State state, float parti state.pitch = entity.getXRot(partialTicks); state.visible = !(entity instanceof BombEntity bomb) || bomb.isVisible(); WeaponDefinition w = entity.weapon(); - WeaponColor c = w != null ? (inWater(entity.level(), entity) ? w.colorInWater : w.color) : WeaponColor.WHITE; + boolean inWater = inWater(entity.level(), entity); + WeaponColor c = w != null ? (inWater ? w.colorInWater : w.color) : WeaponColor.WHITE; state.color = c.argb(); + + state.glowKind = inWater ? GlowKind.NONE : glowKind(entity); + if (state.glowKind != GlowKind.NONE) { + state.glow = Glow.strength(entity.level(), entity.blockPosition()); + state.travel = new Vec3(entity.getX() - entity.xo, entity.getY() - entity.yo, entity.getZ() - entity.zo); + if (state.glowKind == GlowKind.MOTOR) { + state.glowColor = GlowParticleOptions.FIRE; + } else { + // A weapon with its own tint keeps it for the tracer; the default white burns warm. + int tint = state.color & 0xFFFFFF; + state.glowColor = tint == 0xFFFFFF ? TRACER : tint; + } + } + } + + private static GlowKind glowKind(ProjectileEntity entity) { + if (entity instanceof BulletEntity) { + return GlowKind.TRACER; + } + if (entity instanceof RocketEntity || entity instanceof MarkerRocketEntity || entity instanceof AAMissileEntity + || entity instanceof ASMissileEntity || entity instanceof ATMissileEntity || entity instanceof TvMissileEntity) { + return GlowKind.MOTOR; + } + return GlowKind.NONE; } /** The original looked for water at the round and up to two blocks above it. */ @@ -81,6 +136,31 @@ public void submit(State state, PoseStack pose, SubmitNodeCollector collector, C (p, buffer) -> MeshRenderer.emit(mesh.groups(), p, buffer, FULL_BRIGHT, OverlayTexture.NO_OVERLAY, color)); pose.popPose(); } + if (state.visible) { + submitGlow(state, pose, collector, camera); + } super.submit(state, pose, collector, camera); } + + private static void submitGlow(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + if (state.glowKind == GlowKind.NONE || state.glow <= 0.0F) { + return; + } + double speed = state.travel.length(); + Vec3 back = speed > 1.0E-3 ? state.travel.scale(-1.0 / speed) : Vec3.ZERO; + switch (state.glowKind) { + case TRACER -> { + Vec3 toCamera = new Vec3(state.x - camera.pos.x, state.y - camera.pos.y, state.z - camera.pos.z); + Glow.streak(pose, collector, toCamera, back.scale(Math.min(speed, MAX_TRACER)), 0.08F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, Vec3.ZERO, 0.35F, state.glowColor, state.glow); + } + case MOTOR -> { + Vec3 nozzle = back.scale(MOTOR_OFFSET); + Glow.halo(pose, collector, camera, nozzle, 1.1F, state.glowColor, state.glow); + Glow.halo(pose, collector, camera, nozzle, 0.4F, GlowParticleOptions.FLASH, state.glow); + } + default -> { + } + } + } } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java index a58e20d..c17ba3c 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java +++ b/forge-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.particle.GlowParticleOptions; import com.jnhprojects.mcheli.physics.AircraftRotation; import com.jnhprojects.mcheli.physics.GroundImpact; import com.jnhprojects.mcheli.registry.ModItems; @@ -149,8 +150,14 @@ 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; + /** How long a wreck stays, burning, before it disappears: 30 seconds. */ + private static final int WRECK_TICKS = 600; + /** The wreck fire dies down over the last five seconds. */ + private static final int WRECK_FADE_TICKS = 100; /** Ticks until a destroyed wreck disappears; above zero means destroyed. Server only. */ private int despawnCount; + /** Ticks the client has seen this vehicle as a wreck, for the fire to die down on time. */ + private int wreckTicks; private float destroyedYaw; private float destroyedPitch; private float destroyedRoll; @@ -1533,8 +1540,9 @@ public boolean hurtServer(ServerLevel level, DamageSource source, float damage) entityData.set(DATA_HP, Math.max(hp() - (int) damage, 0)); } setHurtDir(-getHurtDir()); + // No markHurt(): it sends the server's motion to every client, which snaps a vehicle + // its pilot is flying back a step and looks like knockback on every hit. setHurtTime(10); - markHurt(); if (hp() <= 0 || packUp) { if (!packUp) { entityData.set(DATA_HP, 0); @@ -1571,10 +1579,10 @@ private boolean hasPlayerPassenger() { return false; } - /** {@code destroyAircraft}: the wreck tumbles, burns and disappears after 25 seconds. Server only. */ + /** {@code destroyAircraft}: the wreck tumbles, burns and disappears after 30 seconds. Server only. */ private void destroyVehicle() { AircraftDefinition def = definition(); - despawnCount = def != null && def.targetDrone ? 50 : 500; + despawnCount = def != null && def.targetDrone ? 50 : WRECK_TICKS; destroyedPitch = random.nextFloat() - 0.5F; destroyedRoll = (random.nextFloat() - 0.5F) * 0.5F; destroyedYaw = 0.0F; @@ -1753,6 +1761,37 @@ private void damageSmoke(AircraftDefinition def) { if (random.nextInt(3) == 0) { level().addParticle(ParticleTypes.FLAME, at.x + random.nextGaussian() * 0.5, at.y, at.z + random.nextGaussian() * 0.5, 0.0, 0.05, 0.0); } + wreckFire(def); + } + } + + /** + * Not in the original: the wreck keeps burning until it disappears, with a fire glow that + * lights up the smoke at night and dies down over the last few seconds. Nothing burns under + * water. Client only. + */ + private void wreckFire(AircraftDefinition def) { + wreckTicks++; + if (isInWater()) { + return; + } + float left = Math.clamp((float) (WRECK_TICKS - wreckTicks) / WRECK_FADE_TICKS, 0.15F, 1.0F); + Vec3 centre = position().add(0.0, def.bodyHeight / 2.0, 0.0); + if (wreckTicks % 8 == 0) { + // Each glow outlives the gap to the next, so the fire never goes dark in between. + float size = Math.clamp(def.bodyWidth * 0.8F, 2.0F, 8.0F) * left; + level().addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.FIRE, size, 24), true, + centre.x, centre.y, centre.z, 0.0, 0.02, 0.0); + } + if (random.nextFloat() < left) { + double spread = def.bodyWidth * 0.4; + level().addParticle(ParticleTypes.FLAME, centre.x + random.nextGaussian() * spread, centre.y, + centre.z + random.nextGaussian() * spread, 0.0, 0.04, 0.0); + } + if (random.nextInt(10) == 0) { + level().addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.EMBER, 0.3F + random.nextFloat() * 0.3F, + 20 + random.nextInt(20)), true, centre.x, centre.y, centre.z, + (random.nextDouble() - 0.5) * 0.15, 0.1 + random.nextDouble() * 0.15, (random.nextDouble() - 0.5) * 0.15); } } @@ -1828,7 +1867,7 @@ protected void readAdditionalSaveData(ValueInput input) { entityData.set(DATA_GUNNER_MODE, input.getBooleanOr("GunnerMode", false)); despawnCount = input.getIntOr("DespawnCount", 0); if (hp() <= 0 && despawnCount <= 0) { - despawnCount = 500; + despawnCount = WRECK_TICKS; } cookOffBlasts = input.getIntOr("CookOff", 0); cookOffDelay = cookOffBlasts > 0 ? 20 : 0; diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java index 0d190d4..a564a1f 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java @@ -55,6 +55,11 @@ private int fuse() { return entityData.get(DATA_FUSE); } + /** A smoke grenade rather than a burning flare. */ + public boolean isSmoke() { + return fuse() > 0; + } + @Override public void tick() { if (fuse() > 0 && tickCount >= fuse()) { diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java new file mode 100644 index 0000000..63a3577 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/particle/GlowParticleOptions.java @@ -0,0 +1,57 @@ +package com.jnhprojects.mcheli.particle; + +import com.jnhprojects.mcheli.registry.ModParticles; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.util.ExtraCodecs; + +/** + * A soft ball of light blended on top of the scene: the flash of an explosion, a burning wreck, an + * ember. Spawned on the client only, but the codecs let {@code /particle mcheli:glow} show it too. + * + * @param color RGB of the light + * @param size radius in blocks at its largest + * @param lifetime ticks until it has faded out + * @param flicker whether it wavers like a fire rather than fading smoothly like a flash + */ +public record GlowParticleOptions(int color, float size, int lifetime, boolean flicker) implements ParticleOptions { + /** White hot at the centre of a blast. */ + public static final int FLASH = 0xFFE2B0; + /** Burning fuel and the fire inside the smoke. */ + public static final int FIRE = 0xFF8A2E; + /** Embers and the deeper red at the edge of a fireball. */ + public static final int EMBER = 0xFF5A1E; + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(i -> i.group( + ExtraCodecs.RGB_COLOR_CODEC.fieldOf("color").forGetter(GlowParticleOptions::color), + ExtraCodecs.POSITIVE_FLOAT.fieldOf("size").forGetter(GlowParticleOptions::size), + ExtraCodecs.POSITIVE_INT.fieldOf("lifetime").forGetter(GlowParticleOptions::lifetime), + Codec.BOOL.optionalFieldOf("flicker", false).forGetter(GlowParticleOptions::flicker)) + .apply(i, GlowParticleOptions::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.INT, GlowParticleOptions::color, + ByteBufCodecs.FLOAT, GlowParticleOptions::size, + ByteBufCodecs.VAR_INT, GlowParticleOptions::lifetime, + ByteBufCodecs.BOOL, GlowParticleOptions::flicker, + GlowParticleOptions::new); + + public static GlowParticleOptions flash(int color, float size, int lifetime) { + return new GlowParticleOptions(color, size, lifetime, false); + } + + public static GlowParticleOptions fire(int color, float size, int lifetime) { + return new GlowParticleOptions(color, size, lifetime, true); + } + + @Override + public ParticleType getType() { + return ModParticles.GLOW.get(); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java new file mode 100644 index 0000000..956a36b --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModParticles.java @@ -0,0 +1,37 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.RegistryObject; + +public final class ModParticles { + private static final DeferredRegister> PARTICLE_TYPES = DeferredRegister.create(Registries.PARTICLE_TYPE, MCHeli.MOD_ID); + + /** Ignores the particle limit: an explosion that loses its flash to the cap looks broken. */ + public static final RegistryObject> GLOW = PARTICLE_TYPES.register("glow", + () -> new ParticleType<>(true) { + @Override + public MapCodec codec() { + return GlowParticleOptions.CODEC; + } + + @Override + public StreamCodec streamCodec() { + return GlowParticleOptions.STREAM_CODEC; + } + }); + + private ModParticles() { + } + + public static void register(BusGroup modBus) { + PARTICLE_TYPES.register(modBus); + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java index 050ee21..4f4b0dc 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.weapon; +import com.jnhprojects.mcheli.particle.GlowParticleOptions; import net.minecraft.core.BlockPos; import net.minecraft.core.particles.BlockParticleOption; import net.minecraft.core.particles.ParticleTypes; @@ -21,6 +22,8 @@ public final class ExplosionEffect { /** The largest crash fireball drawn; beyond this the particle count gets out of hand. */ public static final float MAX_CRASH_SIZE = 12.0F; + /** The widest explosion flash, in blocks; past this it washes out the whole screen. */ + private static final float MAX_FLASH_RADIUS = 30.0F; private ExplosionEffect() { } @@ -29,16 +32,23 @@ private ExplosionEffect() { * Not in the original: a crashing vehicle goes up in a far bigger show than its fuel explosion * alone makes. The same shell of smoke as a missile hit in mid-air, a cluster of fireballs * around it, a plume of smoke that keeps climbing for seconds and burning fuel thrown about. + * The fire glows for several seconds afterwards, which at night lights up the smoke above it. */ public static void crash(Level level, double x, double y, double z, float size) { play(level, x, y, z, size, false); RandomSource random = level.getRandom(); + level.addAlwaysVisibleParticle(GlowParticleOptions.flash(GlowParticleOptions.FLASH, Math.min(size * 3.0F, MAX_FLASH_RADIUS), 20), + true, x, y + size * 0.3, z, 0.0, 0.0, 0.0); int fireballs = Math.max(3, (int) (size / 2.0F)); for (int i = 0; i < fireballs; i++) { double r = size * 0.5 * random.nextDouble(); double a = random.nextDouble() * Math.PI * 2.0; - level.addAlwaysVisibleParticle(ParticleTypes.EXPLOSION_EMITTER, true, - x + Math.cos(a) * r, y + random.nextDouble() * size * 0.4, z + Math.sin(a) * r, 1.0, 0.0, 0.0); + double fx = x + Math.cos(a) * r; + double fy = y + random.nextDouble() * size * 0.4; + double fz = z + Math.sin(a) * r; + level.addAlwaysVisibleParticle(ParticleTypes.EXPLOSION_EMITTER, true, fx, fy, fz, 1.0, 0.0, 0.0); + level.addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.FIRE, size * (0.4F + random.nextFloat() * 0.3F), + 80 + random.nextInt(60)), true, fx, fy, fz, 0.0, 0.01, 0.0); } for (int i = 0; i < size * 4; i++) { level.addAlwaysVisibleParticle(ParticleTypes.CAMPFIRE_SIGNAL_SMOKE, true, @@ -48,8 +58,32 @@ public static void crash(Level level, double x, double y, double z, float size) for (int i = 0; i < size * 10; i++) { double a = random.nextDouble() * Math.PI * 2.0; double speed = (0.2 + random.nextDouble() * 0.4) * size / 6.0; + double up = random.nextDouble() * speed; level.addAlwaysVisibleParticle(random.nextInt(4) == 0 ? ParticleTypes.LAVA : ParticleTypes.FLAME, true, x, y + 0.5, z, - Math.cos(a) * speed, random.nextDouble() * speed, Math.sin(a) * speed); + Math.cos(a) * speed, up, Math.sin(a) * speed); + if (i % 4 == 0) { + level.addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.EMBER, 0.5F + random.nextFloat() * 0.4F, + 20 + random.nextInt(20)), true, x, y + 0.5, z, Math.cos(a) * speed, up, Math.sin(a) * speed); + } + } + } + + /** + * Not in the original: the light of the blast itself, a white hot flash inside a wider red + * glow, and a few embers thrown out. Faint by day; at night it lights up the smoke. + */ + private static void flash(Level level, double x, double y, double z, float size) { + RandomSource random = level.getRandom(); + float radius = Math.min(2.0F + size * 1.5F, MAX_FLASH_RADIUS); + level.addAlwaysVisibleParticle(GlowParticleOptions.flash(GlowParticleOptions.FLASH, radius, 8 + (int) size), true, x, y, z, 0.0, 0.0, 0.0); + level.addAlwaysVisibleParticle(GlowParticleOptions.flash(GlowParticleOptions.EMBER, Math.min(radius * 1.6F, MAX_FLASH_RADIUS), + 14 + (int) (size * 2.0F)), true, x, y, z, 0.0, 0.0, 0.0); + int embers = Math.min((int) (size * 2.0F), 24); + for (int i = 0; i < embers; i++) { + double a = random.nextDouble() * Math.PI * 2.0; + double speed = 0.2 + random.nextDouble() * 0.3 * Math.min(size, 4.0F); + level.addAlwaysVisibleParticle(GlowParticleOptions.fire(GlowParticleOptions.EMBER, 0.3F + random.nextFloat() * 0.3F, + 15 + random.nextInt(15)), true, x, y, z, Math.cos(a) * speed, 0.1 + random.nextDouble() * speed, Math.sin(a) * speed); } } @@ -63,6 +97,7 @@ public static void play(Level level, double x, double y, double z, float size, b } RandomSource random = level.getRandom(); level.addAlwaysVisibleParticle(size >= 2.0F ? ParticleTypes.EXPLOSION_EMITTER : ParticleTypes.EXPLOSION, true, x, y, z, 1.0, 0.0, 0.0); + flash(level, x, y, z, size); int count = 0; int puffEvery = (int) Math.max(size, 4.0F); diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java index 6dcbd1a..41a7bbd 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.ServerConfig; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.entity.projectile.FlareEntity; import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; import com.jnhprojects.mcheli.network.ExplosionEffectPacket; @@ -41,6 +42,8 @@ public final class Explosions { /** Players this far away still see the smoke of an explosion. */ private static final double EFFECT_RANGE = 256.0; + /** Share of the blast knockback an empty vehicle takes; they are far heavier than a mob. */ + private static final double VEHICLE_PUSH = 0.25; private Explosions() { } @@ -148,7 +151,13 @@ private static boolean damageEntities(ServerLevel level, Vec3 center, Params p) } } Combat.hurt(entity, source, damage, p.weapon); - if (!(entity instanceof ProjectileEntity)) { + if (entity instanceof AircraftEntity vehicle) { + // Not in the original: a vehicle someone is driving is simulated on their client, + // so a push here only snaps it back a step. An empty one still rocks a little. + if (vehicle.getControllingPassenger() == null) { + entity.push(dx * impact * VEHICLE_PUSH * 0.4, dy * impact * VEHICLE_PUSH * 0.1, dz * impact * VEHICLE_PUSH * 0.4); + } + } else if (!(entity instanceof ProjectileEntity)) { entity.push(dx * impact * 0.4, dy * impact * 0.1, dz * impact * 0.4); } if (damage > 0.0F && p.fireSeconds > 0) { diff --git a/forge-26.3/src/main/resources/assets/mcheli/particles/glow.json b/forge-26.3/src/main/resources/assets/mcheli/particles/glow.json new file mode 100644 index 0000000..296e539 --- /dev/null +++ b/forge-26.3/src/main/resources/assets/mcheli/particles/glow.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "mcheli:glow" + ] +} diff --git a/forge-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png b/forge-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png new file mode 100644 index 0000000..339b972 Binary files /dev/null and b/forge-26.3/src/main/resources/assets/mcheli/textures/particle/glow.png differ