From 38641a223189c8a1277b8f064e922993ab8a7b26 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 19:10:14 +0200 Subject: [PATCH 1/3] docs: show a harvest gradually, as a client-only effect over voidstone Co-Authored-By: Claude Opus 5.5 --- docs/spec/void-energy.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/spec/void-energy.md b/docs/spec/void-energy.md index cb1e073..1bebd19 100644 --- a/docs/spec/void-energy.md +++ b/docs/spec/void-energy.md @@ -113,6 +113,17 @@ Displacer or the player's Void Siphon, as a small butterfly with flapping wings, our own sprite, after Boulder Dash's butterflies. They are cosmetic and credited at once, but the count is exact: like XP orbs, a butterfly stands for 1, 8 or 64 motes by its size. +Every harvest also shows the void taking the block, over 20 ticks, a Displacer's or a Siphon's alike, +so a Displacer's growth reads as a continuous wave. Like the butterflies it is only a look: the +position is Voidstone from the harvest's tick, for its network, the next harvest and anyone mining, +and the motes and what is given back arrive then too. Each client draws what was harvested over the +new Voidstone and eats it away through 12 irregular stages, after Just Dire Things' goo, each face +on its own and turned by the position so neighbours differ. A solid block is drawn as its own model +and sheds crumbs as it goes; a lava source as a full cube of still lava, bright; air as the End's +starfield, which recedes as if the void hardened into Voidstone. The harvest's sound plays as it +starts. A spent Displacer becoming Voidstone shows the same, its own casing eaten away. A chunk that +unloads drops the effect, and nothing of it is saved. + - **End**: places fed blocks into the empty air of its zone. It faces the way its placer looked and grows only into the space ahead of that face, so it never walls in its other faces. - **Overworld**: swaps fed blocks with natural terrain in its zone — the blocks in a natural-block From a414682340d1b68e27265a8d74012e1ab9624bb3 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 19:29:49 +0200 Subject: [PATCH 2/3] feat: show the void taking a harvested block over a second Each harvest, by a Void Displacer or a Void Siphon, and a spent Displacer becoming Voidstone, posts a VoidTakesBlockEvent and sends a VoidTaking to the players tracking the position. The client draws what was there over the Voidstone, a hair larger, and eats it away through 12 stage masks over 20 ticks: a solid block's own model shedding crumbs, a fluid as a cube of its still texture, air as the End's starfield. The masks are drawn as geometry, each face only over the pixels the void has not reached, so the vanilla render types serve. The server mechanic is unchanged: the position is Voidstone from the harvest's tick. The 12 masks are placeholders scripts/build-textures.py generates, until #107 draws them. Closes #98 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + scripts/build-textures.py | 40 +++ .../github/_5thlayer/voidworks/Voidworks.java | 2 + .../voidworks/client/VoidTakings.java | 328 ++++++++++++++++++ .../voidworks/client/VoidworksClient.java | 7 +- .../displacer/DisplacerBlockEntity.java | 13 +- .../voidworks/gametest/DisplacerTests.java | 29 ++ .../voidworks/gametest/SiphonTests.java | 42 +++ .../_5thlayer/voidworks/gametest/Takings.java | 59 ++++ .../voidworks/siphon/SiphonHarvest.java | 9 +- .../voidworks/taking/TakingMask.java | 122 +++++++ .../voidworks/taking/TakingStages.java | 30 ++ .../voidworks/taking/VoidTakesBlockEvent.java | 42 +++ .../voidworks/taking/VoidTaking.java | 59 ++++ .../textures/void_taking/stage_1.png | Bin 0 -> 128 bytes .../textures/void_taking/stage_10.png | Bin 0 -> 127 bytes .../textures/void_taking/stage_11.png | Bin 0 -> 125 bytes .../textures/void_taking/stage_12.png | Bin 0 -> 86 bytes .../textures/void_taking/stage_2.png | Bin 0 -> 138 bytes .../textures/void_taking/stage_3.png | Bin 0 -> 146 bytes .../textures/void_taking/stage_4.png | Bin 0 -> 159 bytes .../textures/void_taking/stage_5.png | Bin 0 -> 154 bytes .../textures/void_taking/stage_6.png | Bin 0 -> 151 bytes .../textures/void_taking/stage_7.png | Bin 0 -> 160 bytes .../textures/void_taking/stage_8.png | Bin 0 -> 147 bytes .../textures/void_taking/stage_9.png | Bin 0 -> 140 bytes .../voidworks/taking/ShippedMasksTest.java | 84 +++++ .../voidworks/taking/TakingMaskTest.java | 152 ++++++++ .../voidworks/taking/TakingStagesTest.java | 64 ++++ 29 files changed, 1068 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java create mode 100644 src/main/java/io/github/_5thlayer/voidworks/gametest/Takings.java create mode 100644 src/main/java/io/github/_5thlayer/voidworks/taking/TakingMask.java create mode 100644 src/main/java/io/github/_5thlayer/voidworks/taking/TakingStages.java create mode 100644 src/main/java/io/github/_5thlayer/voidworks/taking/VoidTakesBlockEvent.java create mode 100644 src/main/java/io/github/_5thlayer/voidworks/taking/VoidTaking.java create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_1.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_10.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_11.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_12.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_2.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_3.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_4.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_5.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_6.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_7.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_8.png create mode 100644 src/main/resources/assets/voidworks/textures/void_taking/stage_9.png create mode 100644 src/test/java/io/github/_5thlayer/voidworks/taking/ShippedMasksTest.java create mode 100644 src/test/java/io/github/_5thlayer/voidworks/taking/TakingMaskTest.java create mode 100644 src/test/java/io/github/_5thlayer/voidworks/taking/TakingStagesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 464d59c..cc43706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Written for Consumers: what a mod building against Voidworks can use, or will se ## Unreleased +- A harvest is seen as the void taking the block over a second: the harvested block, drawn over the Voidstone now there, is eaten away through 12 irregular stages until only the Voidstone is left, for a Void Displacer's harvest, a Void Siphon's by hand, and a spent Displacer becoming Voidstone, which eats away its own casing. A solid block sheds crumbs as it goes, in place of its burst; a lava source is a bright cube of still lava, and air the End's starfield receding, as if the void hardened into Voidstone. It is only a look: the position is Voidstone from the harvest's tick, and what it yields and the network it joins are as before. The stage masks, `assets/voidworks/textures/void_taking/stage_<1-12>.png`, are placeholders, each painting what the void has reached by its stage; a resource pack can draw its own. Each such moment posts a `VoidTakesBlockEvent` on the NeoForge event bus, on the server, with the position and what it held, then sends a `VoidTaking` to the players tracking it. (#98) - A Void Dragon's rider watches it start down into the Well after they jump, sitting on its back as before, and sees the void only half a second after their eyes go below the Well's rim into the shaft (`VoidDragon.VOID_DELAY_TICKS`), the dragon diving on meanwhile; before, the screen went black as they jumped, while the dragon still hovered over the Well. They are unseen from the jump on, now from the jump's own tick. `VoidDragon.inTheVoid()` says whether a presence has taken its rider into the shaft, and its rider stands at its feet only from then; `carrying()` and `DragonRiding.inTheDive` still hold from the jump. (#84) - A Void Dragon is seen again wherever it is after its presence's chunk unloaded: that presence, never saved, was left out of the world but still counted as the dragon's, so no new one showed, at the Well it was called to or anywhere else, and it came back at its old place with the chunk. A presence where nothing ticks is now taken away, and a new one shows where its dragon is. (#79) - A grown Void Dragon with nothing to do away from its owner's Wells, such as one just grown from a Dragonling, dives to the nearest of them and rests there, so it can be ridden and called. Before, one with no trip to make stayed where it grew up for good. (#79) diff --git a/scripts/build-textures.py b/scripts/build-textures.py index cdc4ae2..a6d5c86 100644 --- a/scripts/build-textures.py +++ b/scripts/build-textures.py @@ -53,9 +53,15 @@ soaking, its cracks spread at each of the four stages and glow the brighter in amethyst, each stage's cracks a sprite of their own laid over the shell. +The void's 12 stage masks, which a block it takes is eaten away through, are placeholders until they +are drawn (#107): the void spreads from a few spots, each pixel reached as soon as the nearest spot, +its distance roughened by noise, comes to it, the same share of the face more at every stage. A mask +paints, in the void's darkest purple, every pixel reached by its stage, so each covers the last. + Run from the repository root; it needs Pillow. """ +import random import shutil from pathlib import Path @@ -113,6 +119,13 @@ EGG_CRACK_DARK = (0x3E, 0x38, 0x30) EGG_GLOW = [(0x7A, 0x3C, 0xB0), (0xA0, 0x5A, 0xE0), (0xC8, 0x8C, 0xFF), (0xE8, 0xB0, 0xFF)] +# The void's stage masks: how many stages, the spots it spreads from on a face, how rough its edge is, +# in pixels, and the seed that fixes both, so the placeholders come out the same on every run. +TAKING_STAGES = 12 +TAKING_SPOTS = 3 +TAKING_ROUGHNESS = 3.0 +TAKING_SEED = 98 + # The abyssium's darkest and middling purples, which Voidstone's darkest and lightest pixels become. VOIDSTONE_DARK = (0x0F, 0x00, 0x1D) VOIDSTONE_LIGHT = (0x62, 0x12, 0x50) @@ -294,6 +307,32 @@ def drained_dragon_egg(): save(cracks, TEXTURES / "block" / f"drained_dragon_egg_cracks_{stage}.png") +def taking_masks(): + rng = random.Random(TAKING_SEED) + spots = [(rng.uniform(0, 16), rng.uniform(0, 16)) for _ in range(TAKING_SPOTS)] + # Value noise on a coarse grid, read smoothly between its points, so the edge is ragged, not round. + grid = [[rng.uniform(0, TAKING_ROUGHNESS) for _ in range(6)] for _ in range(6)] + + def noise(x, y): + gx, gy = x / 3.2, y / 3.2 + x0, y0 = int(gx), int(gy) + fx, fy = gx - x0, gy - y0 + top = grid[y0][x0] * (1 - fx) + grid[y0][x0 + 1] * fx + bottom = grid[y0 + 1][x0] * (1 - fx) + grid[y0 + 1][x0 + 1] * fx + return top * (1 - fy) + bottom * fy + + def reached(pixel): + x, y = pixel[0] + 0.5, pixel[1] + 0.5 + return min(((x - sx) ** 2 + (y - sy) ** 2) ** 0.5 for sx, sy in spots) + noise(x, y) + rng.uniform(0, 0.5) + + order = sorted(((x, y) for y in range(16) for x in range(16)), key=reached) + for stage in range(1, TAKING_STAGES + 1): + mask = Image.new("RGBA", (16, 16)) + for pixel in order[: round(len(order) * stage / TAKING_STAGES)]: + mask.putpixel(pixel, (*VOIDSTONE_DARK, 255)) + save(mask, TEXTURES / "void_taking" / f"stage_{stage}.png") + + def main(): void_siphon() void_siphon("resonant_void_siphon", RESONANT_CRYSTAL) @@ -316,6 +355,7 @@ def main(): void_crucible(abyssium) void_well() drained_dragon_egg() + taking_masks() if __name__ == "__main__": diff --git a/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java b/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java index 7d79a98..13b792b 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java +++ b/src/main/java/io/github/_5thlayer/voidworks/Voidworks.java @@ -19,6 +19,7 @@ import io.github._5thlayer.voidworks.network.Conduction; import io.github._5thlayer.voidworks.pressure.DimensionPressure; import io.github._5thlayer.voidworks.pressure.VoidPressure; +import io.github._5thlayer.voidworks.taking.VoidTaking; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; @@ -49,6 +50,7 @@ public Voidworks(IEventBus modBus, ModContainer container) { VoidworksEntities.register(modBus); modBus.addListener(WorksTabContents::add); Displacement.register(modBus); + VoidTaking.register(modBus); Dormancy.register(modBus); DiveColour.register(modBus); EggPrice.register(modBus); diff --git a/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java b/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java new file mode 100644 index 0000000..eb644d5 --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.client; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import com.mojang.blaze3d.platform.NativeImage; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.logging.LogUtils; +import io.github._5thlayer.voidworks.Voidworks; +import io.github._5thlayer.voidworks.taking.TakingMask; +import io.github._5thlayer.voidworks.taking.TakingStages; +import io.github._5thlayer.voidworks.taking.VoidTaking; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.model.geom.builders.UVPair; +import net.minecraft.client.renderer.FaceInfo; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import net.minecraft.util.ARGB; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.RandomSource; +import net.minecraft.util.context.ContextKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.ExtractLevelRenderStateEvent; +import net.neoforged.neoforge.client.event.SubmitCustomGeometryEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.ChunkEvent; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +/** + * The void seen taking blocks ({@link VoidTaking}): each block it takes is drawn over the Voidstone + * now there, a hair larger, and eaten away through the {@link TakingMask} over + * {@link TakingStages#DURATION} ticks, until only the Voidstone is left. A solid block is drawn as its + * own model and sheds crumbs as it goes, a fluid as a full cube of its still texture, glowing if it + * glows, and air as the End's starfield. A taking whose chunk unloads is dropped, and nothing of it + * outlives the session. + */ +final class VoidTakings { + + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final ContextKey> DRAWN = new ContextKey<>(Identifier.fromNamespaceAndPath(Voidworks.MOD_ID, "void_takings")); + + private static final Vector3fc BLOCK_FROM = new Vector3f(0, 0, 0); + private static final Vector3fc BLOCK_TO = new Vector3f(1, 1, 1); + + /** How much larger than the block it is drawn, so it never fights the Voidstone's faces. */ + private static final float GROWN = 1.002F; + + /** The crumbs a solid block sheds while it is taken: as many as it once burst into, spread over its stages. */ + private static final float CRUMBS_PER_TICK = 30F / TakingStages.DURATION; + + private static final List SHOWN = new ArrayList<>(); + + /** The masks, once the client's resources have loaded them; nothing is drawn without them. */ + private static @Nullable TakingMask mask; + + private VoidTakings() { + } + + static void register(IEventBus modBus) { + modBus.addListener((AddClientReloadListenersEvent event) -> event.addListener( + Identifier.fromNamespaceAndPath(Voidworks.MOD_ID, "void_taking_masks"), (ResourceManagerReloadListener) VoidTakings::loadMasks)); + NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> tick()); + NeoForge.EVENT_BUS.addListener(VoidTakings::extract); + NeoForge.EVENT_BUS.addListener(VoidTakings::submit); + NeoForge.EVENT_BUS.addListener((ChunkEvent.Unload event) -> { + if (event.getLevel().isClientSide()) { + var chunk = event.getChunk().getPos(); + SHOWN.removeIf(taking -> ChunkPos.containing(taking.pos()).equals(chunk)); + } + }); + NeoForge.EVENT_BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> SHOWN.clear()); + } + + static void receive(VoidTaking taking) { + SHOWN.add(taking); + } + + private static void loadMasks(ResourceManager resources) { + var masks = new ArrayList(); + try { + for (int stage = 1; stage <= TakingStages.STAGES; stage++) { + try (var in = resources.open(TakingMask.path(stage)); var image = NativeImage.read(in)) { + var painted = new boolean[TakingMask.SIZE * TakingMask.SIZE]; + for (int y = 0; y < TakingMask.SIZE; y++) { + for (int x = 0; x < TakingMask.SIZE; x++) { + painted[y * TakingMask.SIZE + x] = TakingMask.paints(image.getPixel(x * image.getWidth() / TakingMask.SIZE, + y * image.getHeight() / TakingMask.SIZE)); + } + } + masks.add(painted); + } + } + mask = TakingMask.of(masks); + } catch (IOException | IllegalArgumentException unreadable) { + LOGGER.error("Voidworks could not read the void's stage masks, so the void takes blocks unseen", unreadable); + mask = null; + } + } + + /** Drops what is over and has a solid block being taken shed its crumbs. */ + private static void tick() { + var minecraft = Minecraft.getInstance(); + var level = minecraft.level; + if (level == null) { + SHOWN.clear(); + return; + } + if (minecraft.isPaused()) { + return; + } + long now = level.getGameTime(); + SHOWN.removeIf(taking -> TakingStages.over(now - taking.start())); + var random = level.getRandom(); + for (var taking : SHOWN) { + if (solid(taking.taken())) { + int crumbs = (int) CRUMBS_PER_TICK + (random.nextFloat() < CRUMBS_PER_TICK % 1 ? 1 : 0); + for (int i = 0; i < crumbs; i++) { + var at = Vec3.atCenterOf(taking.pos()).add(random.nextFloat() - 0.5, random.nextFloat() - 0.5, random.nextFloat() - 0.5); + level.addParticle(new BlockParticleOption(ParticleTypes.BLOCK, taking.taken()), at.x, at.y, at.z, 0, 0, 0); + } + } + } + } + + /** One face of what is being taken, drawn in {@link #type} over the runs of it the void has not reached. */ + private record Face(RenderType type, Vector3fc[] corners, float[] uvs, int colour, int light, List left) { + } + + /** What one taking draws this frame, its faces around {@link #pos}. */ + private record Drawn(BlockPos pos, List faces) { + } + + private static void extract(ExtractLevelRenderStateEvent event) { + var mask = VoidTakings.mask; + var drawn = new ArrayList(); + if (mask != null) { + var level = event.getLevel(); + float now = level.getGameTime() + event.getDeltaTracker().getGameTimeDeltaPartialTick(false); + for (var taking : SHOWN) { + float elapsed = now - taking.start(); + if (!TakingStages.over(elapsed) && event.getFrustum().isVisible(new AABB(taking.pos()))) { + var faces = faces(level, taking, mask, TakingStages.reached(elapsed)); + if (!faces.isEmpty()) { + drawn.add(new Drawn(taking.pos(), faces)); + } + } + } + } + event.getRenderState().setRenderData(DRAWN, drawn); + } + + private static List faces(ClientLevel level, VoidTaking taking, TakingMask mask, int reached) { + var faces = new ArrayList(); + var pos = taking.pos(); + var taken = taking.taken(); + if (taken.isAir()) { + for (var direction : Direction.values()) { + if (shown(level, pos, taken, direction)) { + faces.add(cubeFace(RenderTypes.endPortal(), direction, null, -1, 0, mask.left(reached, TakingMask.orientation(pos, direction)))); + } + } + } else if (!taken.getFluidState().isEmpty()) { + var fluid = taken.getFluidState(); + var model = Minecraft.getInstance().getModelManager().getFluidStateModelSet().get(fluid); + int tint = model.fluidTintSource() != null ? model.fluidTintSource().colorInWorld(fluid, taken, level, pos) : -1; + boolean glows = taken.getLightEmission(level, pos) > 0; + var type = model.layer().translucent() ? RenderTypes.translucentMovingBlock() : RenderTypes.cutoutMovingBlock(); + for (var direction : Direction.values()) { + if (shown(level, pos, taken, direction)) { + int colour = glows ? tint : ARGB.scaleRGB(tint, level.cardinalLighting().byFace(direction)); + int light = glows ? LightCoordsUtil.FULL_BRIGHT : LevelRenderer.getLightCoords(level, pos.relative(direction)); + faces.add(cubeFace(type, direction, model.stillMaterial().sprite(), colour, light, + mask.left(reached, TakingMask.orientation(pos, direction)))); + } + } + } else { + var model = Minecraft.getInstance().getModelManager().getBlockStateModelSet().get(taken); + var parts = new ArrayList(); + model.collectParts(level, pos, taken, RandomSource.create(taken.getSeed(pos)), parts); + var colours = Minecraft.getInstance().getBlockColors(); + for (var part : parts) { + for (var culled : CULLED) { + if (culled != null && !shown(level, pos, taken, culled)) { + continue; + } + for (var quad : part.getQuads(culled)) { + var direction = quad.direction(); + var material = quad.materialInfo(); + int colour = -1; + if (material.isTinted()) { + var source = colours.getTintSource(taken, material.tintIndex()); + colour = source != null ? ARGB.opaque(source.colorInWorld(taken, level, pos)) : -1; + } + if (material.shade()) { + colour = ARGB.scaleRGB(colour, level.cardinalLighting().byFace(direction)); + } + var corners = new Vector3fc[4]; + var uvs = new float[8]; + for (int i = 0; i < 4; i++) { + corners[i] = quad.position(i); + uvs[2 * i] = UVPair.unpackU(quad.packedUV(i)); + uvs[2 * i + 1] = UVPair.unpackV(quad.packedUV(i)); + } + var type = material.layer().translucent() ? RenderTypes.translucentMovingBlock() : RenderTypes.cutoutMovingBlock(); + faces.add(new Face(type, corners, uvs, colour, LevelRenderer.getLightCoords(level, pos.relative(direction)), + mask.left(reached, TakingMask.orientation(pos, direction)))); + } + } + } + } + faces.removeIf(face -> face.left().isEmpty()); + return faces; + } + + /** The faces a model's quads are culled by, and null for those never culled. */ + private static final @Nullable Direction[] CULLED = {null, Direction.DOWN, Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; + + /** @return whether the face of {@code taken} at {@code pos} towards {@code direction} would show, were it still there */ + private static boolean shown(ClientLevel level, BlockPos pos, BlockState taken, Direction direction) { + return Block.shouldRenderFace(taken, level.getBlockState(pos.relative(direction)), direction); + } + + /** + * The block's face towards {@code direction}, corners in the order a model's face has them, with + * {@code sprite} over it whole, or no texture for a render type that takes none. + */ + private static Face cubeFace(RenderType type, Direction direction, @Nullable TextureAtlasSprite sprite, int colour, int light, List left) { + var info = FaceInfo.fromFacing(direction); + var corners = new Vector3fc[4]; + for (int i = 0; i < 4; i++) { + corners[i] = info.getVertexInfo(i).select(BLOCK_FROM, BLOCK_TO); + } + var uvs = sprite == null ? new float[8] : new float[] { + sprite.getU0(), sprite.getV0(), sprite.getU0(), sprite.getV1(), sprite.getU1(), sprite.getV1(), sprite.getU1(), sprite.getV0()}; + return new Face(type, corners, uvs, colour, light, left); + } + + private static void submit(SubmitCustomGeometryEvent event) { + List drawn = event.getLevelRenderState().getRenderData(DRAWN); + if (drawn == null) { + return; + } + var camera = event.getLevelRenderState().cameraRenderState.pos; + var poseStack = event.getPoseStack(); + for (var taking : drawn) { + poseStack.pushPose(); + poseStack.translate(taking.pos().getX() - camera.x, taking.pos().getY() - camera.y, taking.pos().getZ() - camera.z); + for (var face : taking.faces()) { + event.getSubmitNodeCollector().submitCustomGeometry(poseStack, face.type(), (pose, buffer) -> draw(pose, buffer, face)); + } + poseStack.popPose(); + } + } + + /** Draws each run of the face the void has not reached, as the part of the face over those pixels. */ + private static void draw(PoseStack.Pose pose, VertexConsumer buffer, Face face) { + boolean textured = face.type() != RenderTypes.endPortal(); + float size = TakingMask.SIZE; + for (var run : face.left()) { + float s0 = run.x0() / size; + float s1 = run.x1() / size; + float t0 = run.y() / size; + float t1 = (run.y() + 1) / size; + vertex(pose, buffer, face, textured, s0, t0); + vertex(pose, buffer, face, textured, s0, t1); + vertex(pose, buffer, face, textured, s1, t1); + vertex(pose, buffer, face, textured, s1, t0); + } + } + + /** + * The face's point {@code s} along its first and last corners' edge and {@code t} towards its + * second and third's, as its corners wind: corner 0 at (0, 0), 1 at (0, 1), 2 at (1, 1), 3 at (1, 0). + */ + private static void vertex(PoseStack.Pose pose, VertexConsumer buffer, Face face, boolean textured, float s, float t) { + var c = face.corners(); + float x = grown(lerp(lerp(c[0].x(), c[3].x(), s), lerp(c[1].x(), c[2].x(), s), t)); + float y = grown(lerp(lerp(c[0].y(), c[3].y(), s), lerp(c[1].y(), c[2].y(), s), t)); + float z = grown(lerp(lerp(c[0].z(), c[3].z(), s), lerp(c[1].z(), c[2].z(), s), t)); + var vertex = buffer.addVertex(pose, x, y, z); + if (textured) { + var uv = face.uvs(); + vertex.setColor(face.colour()) + .setUv(lerp(lerp(uv[0], uv[6], s), lerp(uv[2], uv[4], s), t), lerp(lerp(uv[1], uv[7], s), lerp(uv[3], uv[5], s), t)) + .setLight(face.light()); + } + } + + private static float grown(float coordinate) { + return 0.5F + (coordinate - 0.5F) * GROWN; + } + + private static float lerp(float from, float to, float by) { + return from + (to - from) * by; + } + + private static boolean solid(BlockState state) { + return !state.isAir() && state.getFluidState().isEmpty(); + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/client/VoidworksClient.java b/src/main/java/io/github/_5thlayer/voidworks/client/VoidworksClient.java index 147e2be..4e5bb4f 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/client/VoidworksClient.java +++ b/src/main/java/io/github/_5thlayer/voidworks/client/VoidworksClient.java @@ -15,6 +15,7 @@ import io.github._5thlayer.voidworks.mutation.KnownBreakdowns; import io.github._5thlayer.voidworks.pressure.KnownPressure; import io.github._5thlayer.voidworks.pressure.PressurePayload; +import io.github._5thlayer.voidworks.taking.VoidTaking; import io.github._5thlayer.voidworks.well.VoidWellBlockEntity; import net.minecraft.client.Minecraft; import net.minecraft.client.particle.SpriteSet; @@ -35,8 +36,8 @@ /** * The Module's client entry point: it draws the butterflies a {@link MoteFlight} or a {@link MoteCrawl} shows and the * result floating over a Void Crucible ({@link VoidCrucibleRenderer}), a Void Well's fill in its shaft - * ({@link VoidWellRenderer}, {@link ShaftButterflies}) and the Void Dragon - * ({@link VoidDragonRenderer}), and keeps the Void Pressure + * ({@link VoidWellRenderer}, {@link ShaftButterflies}), the Void Dragon + * ({@link VoidDragonRenderer}) and the void taking a block ({@link VoidTakings}), and keeps the Void Pressure * entry the server sends ({@link KnownPressure}). */ @Mod(value = Voidworks.MOD_ID, dist = Dist.CLIENT) @@ -49,6 +50,7 @@ public VoidworksClient(IEventBus modBus) { modBus.addListener(VoidworksClient::registerParticles); modBus.addListener(VoidworksClient::registerPayloadHandlers); modBus.addListener(VoidworksClient::registerRenderers); + VoidTakings.register(modBus); // Over the world but under the hotbar: a dragon's rider sees only the void through its dive. modBus.addListener((RegisterGuiLayersEvent event) -> event.registerAbove(VanillaGuiLayers.CAMERA_OVERLAYS, Identifier.fromNamespaceAndPath(Voidworks.MOD_ID, "dive"), DiveOverlay::render)); @@ -100,5 +102,6 @@ private static void registerPayloadHandlers(RegisterClientPayloadHandlersEvent e event.register(PressurePayload.TYPE, (payload, context) -> KnownPressure.receive(payload)); event.register(BreakdownsPayload.TYPE, (payload, context) -> KnownBreakdowns.receive(payload)); event.register(CallPayload.TYPE, (payload, context) -> CallOverlay.receive(payload)); + event.register(VoidTaking.TYPE, (taking, context) -> VoidTakings.receive(taking)); } } diff --git a/src/main/java/io/github/_5thlayer/voidworks/displacer/DisplacerBlockEntity.java b/src/main/java/io/github/_5thlayer/voidworks/displacer/DisplacerBlockEntity.java index 5c5f5e7..e5ec77f 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/displacer/DisplacerBlockEntity.java +++ b/src/main/java/io/github/_5thlayer/voidworks/displacer/DisplacerBlockEntity.java @@ -16,12 +16,11 @@ import io.github._5thlayer.voidworks.energy.VoidEnergy; import io.github._5thlayer.voidworks.inventory.ItemFace; import io.github._5thlayer.voidworks.network.VoidstoneNetworks; +import io.github._5thlayer.voidworks.taking.VoidTaking; import io.github._5thlayer.voidworks.pressure.HarvestKind; import io.github._5thlayer.voidworks.pressure.VoidPressure; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.core.particles.BlockParticleOption; -import net.minecraft.core.particles.ParticleTypes; import net.minecraft.server.level.ServerLevel; import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; @@ -46,7 +45,7 @@ * What every Void Displacer shares: it harvests the positions touching it or the Voidstone it has * left, nearest first, each harvest taking one fed block, turning the position into Voidstone and * putting the fed block's motes, at one grade above the Void Pressure there, into the network its - * Voidstone forms, with a sound, and anything solid it displaced crumbling. It holds no motes + * Voidstone forms, with a sound, and the void seen taking what it displaced ({@link VoidTaking}). It holds no motes * itself. What it harvests, and what its output and tank get for it, is its dimension's * ({@link OverworldDisplacerBlockEntity}, {@link EndDisplacerBlockEntity}, * {@link NetherDisplacerBlockEntity}). @@ -236,11 +235,7 @@ private boolean harvest(ServerLevel level, BlockPos target) { transaction.commit(); } level.setBlock(target, VoidworksBlocks.VOIDSTONE.get().in(level), Block.UPDATE_ALL); - if (!harvested.isAir() && harvested.getFluidState().isEmpty()) { - // What solid was displaced crumbles as if broken, without its breaking sound. - level.sendParticles(new BlockParticleOption(ParticleTypes.BLOCK, harvested), - target.getX() + 0.5, target.getY() + 0.5, target.getZ() + 0.5, 30, 0.3, 0.3, 0.3, 0.05); - } + VoidTaking.show(level, target, harvested); grown.add(target.immutable()); try (var transaction = Transaction.openRoot()) { // The room was asked for above, so the network takes them all. @@ -339,7 +334,9 @@ private boolean outputEmpty() { /** Its Zone spent or nothing left in reach, and its output empty: it becomes Voidstone, joining its network; {@link #preRemoveSideEffects} drops its fed blocks. */ private void becomeVoidstone(ServerLevel level) { + var itself = getBlockState(); level.setBlock(worldPosition, VoidworksBlocks.VOIDSTONE.get().in(level), Block.UPDATE_ALL); + VoidTaking.show(level, worldPosition, itself); } /** Where it cannot work, or never could harvest: it goes back to its item, and {@link #preRemoveSideEffects} drops what it held. */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/DisplacerTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/DisplacerTests.java index 8322eca..bff2f3f 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/DisplacerTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/DisplacerTests.java @@ -73,6 +73,8 @@ static void register(VoidworksGameTests.Registrar tests) { tests.test("a_displacer_where_the_harvest_kind_is_not_overworld_harvests_nothing_and_pops_off", 1, DisplacerTests::refusesOutsideTheOverworldKind); tests.test("an_empty_hand_takes_back_a_displacers_fed_blocks", 1, DisplacerTests::givesBackFed); tests.test("an_empty_hand_takes_a_displacers_output_before_its_fed_blocks", 1, DisplacerTests::givesOutputFirst); + tests.test("void_takes_the_terrain_a_displacer_harvests", TICKS, DisplacerTests::takesTerrain); + tests.test("void_takes_a_spent_displacer_as_it_becomes_voidstone", TICKS, DisplacerTests::takesSpentDisplacer); tests.test("a_displacers_report_names_its_fed_block_and_the_harvests_left_in_its_zone", TICKS, DisplacerTests::report); } @@ -365,6 +367,33 @@ private static void report(GameTestHelper helper) { }); } + private static void takesTerrain(GameTestHelper helper) { + var takings = Takings.watch(helper); + var displacer = place(helper, DISPLACER, 2); + feed(displacer, Items.COPPER_BLOCK, 1); + helper.succeedWhen(() -> { + takings.assertTook(BELOW, Blocks.STONE.defaultBlockState()); + takings.stop(); + }); + } + + /** It takes itself only once its output is empty, so what it takes is the Displacer, not Voidstone. */ + private static void takesSpentDisplacer(GameTestHelper helper) { + var takings = Takings.watch(helper); + var displacer = place(helper, DISPLACER, 1); + var state = helper.getBlockState(DISPLACER); + feed(displacer, Items.COPPER_BLOCK, 1); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(output(displacer, Items.STONE) == 1, "it has not harvested")) + .thenExecute(() -> { + helper.assertTrue(takings.at(DISPLACER) == null, "the void took the Displacer before its output was empty"); + emptyOutput(displacer); + }) + .thenWaitUntil(() -> takings.assertTook(DISPLACER, state)) + .thenExecute(takings::stop) + .thenSucceed(); + } + private static OverworldDisplacerBlockEntity place(GameTestHelper helper, BlockPos pos, int zone) { helper.setBlock(pos, VoidworksBlocks.OVERWORLD_DISPLACER.get()); var displacer = helper.getBlockEntity(pos, OverworldDisplacerBlockEntity.class); diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/SiphonTests.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/SiphonTests.java index aaba298..528b07a 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/gametest/SiphonTests.java +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/SiphonTests.java @@ -69,6 +69,10 @@ static void register(VoidworksGameTests.Registrar tests) { tests.test("a_siphon_smithed_with_an_echo_shard_becomes_resonant_keeping_its_motes_and_toggle", 1, SiphonTests::smithsToResonant); tests.test("a_resonant_siphon_smithed_with_dragons_breath_becomes_draconic_keeping_its_motes_and_toggle", 1, SiphonTests::smithsToDraconic); tests.test("a_resonant_siphon_holds_more_than_a_siphon", 1, SiphonTests::resonantHoldsMore); + tests.test("void_takes_the_terrain_a_siphon_harvests", 1, SiphonTests::takesTerrain); + tests.test("void_takes_the_lava_a_siphon_harvests", 1, SiphonTests::takesLava); + tests.test("void_takes_the_air_a_siphon_harvests", 1, SiphonTests::takesAir); + tests.test("void_takes_nothing_from_a_refused_siphon_harvest", 1, SiphonTests::takesNothingRefused); tests.test("a_draconic_siphon_on_voidstone_charges_at_its_own_rate", 1, SiphonTests::draconicChargesFaster); } @@ -293,6 +297,44 @@ private static void draconicChargesFaster(GameTestHelper helper) { helper.succeed(); } + private static void takesTerrain(GameTestHelper helper) { + var takings = Takings.watch(helper); + var player = player(helper, new ItemStack(Items.COPPER_BLOCK)); + harvest(helper, player, FLOOR, HarvestKind.OVERWORLD); + takings.stop(); + takings.assertTook(FLOOR, Blocks.STONE.defaultBlockState()); + helper.succeed(); + } + + private static void takesLava(GameTestHelper helper) { + var takings = Takings.watch(helper); + var player = player(helper, new ItemStack(Items.IRON_BLOCK)); + player.getInventory().setItem(3, new ItemStack(Items.BUCKET)); + helper.setBlock(FLOOR, Blocks.LAVA); + harvest(helper, player, FLOOR, HarvestKind.NETHER); + takings.stop(); + takings.assertTook(FLOOR, Blocks.LAVA.defaultBlockState()); + helper.succeed(); + } + + private static void takesAir(GameTestHelper helper) { + var takings = Takings.watch(helper); + var player = player(helper, new ItemStack(Items.DIAMOND_BLOCK)); + harvest(helper, player, FLOOR, HarvestKind.END); + takings.stop(); + takings.assertTook(ABOVE, Blocks.AIR.defaultBlockState()); + helper.succeed(); + } + + private static void takesNothingRefused(GameTestHelper helper) { + var takings = Takings.watch(helper); + var player = player(helper, new ItemStack(Items.COBBLESTONE)); + harvest(helper, player, FLOOR, HarvestKind.OVERWORLD); + takings.stop(); + helper.assertTrue(takings.at(FLOOR) == null, "a refused harvest took " + takings.at(FLOOR)); + helper.succeed(); + } + // -- helpers -------------------------------------------------------------------------------- /** A survival player standing on the floor's middle, a Void Siphon in the main hand and {@code fed} in the off hand. */ diff --git a/src/main/java/io/github/_5thlayer/voidworks/gametest/Takings.java b/src/main/java/io/github/_5thlayer/voidworks/gametest/Takings.java new file mode 100644 index 0000000..1ba3d8e --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/gametest/Takings.java @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.gametest; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import io.github._5thlayer.voidworks.taking.VoidTakesBlockEvent; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.common.NeoForge; +import org.jspecify.annotations.Nullable; + +/** + * The blocks the void took in one test's structure, heard through {@link VoidTakesBlockEvent} from + * {@link #watch} until {@link #stop}: what each position held, asked by its position in the structure. + */ +final class Takings implements Consumer { + + private final GameTestHelper helper; + /** By absolute position, as a test can be rotated. */ + private final Map taken = new HashMap<>(); + + private Takings(GameTestHelper helper) { + this.helper = helper; + } + + static Takings watch(GameTestHelper helper) { + var takings = new Takings(helper); + NeoForge.EVENT_BUS.addListener(takings); + return takings; + } + + @Override + public void accept(VoidTakesBlockEvent event) { + if (event.level() == helper.getLevel() && helper.getBounds().contains(Vec3.atCenterOf(event.pos()))) { + taken.put(event.pos(), event.taken()); + } + } + + /** @return what the void took at {@code pos} in the structure, or null when it took nothing there */ + @Nullable BlockState at(BlockPos pos) { + return taken.get(helper.absolutePos(pos)); + } + + /** Asserts that the void took {@code expected} at {@code pos}, in the structure. */ + void assertTook(BlockPos pos, BlockState expected) { + var at = at(pos); + helper.assertTrue(expected.equals(at), "the void took " + at + " at " + pos + ", not " + expected + "; it took " + taken + " (absolute)"); + } + + void stop() { + NeoForge.EVENT_BUS.unregister(this); + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/siphon/SiphonHarvest.java b/src/main/java/io/github/_5thlayer/voidworks/siphon/SiphonHarvest.java index 940cb37..0d9fa46 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/siphon/SiphonHarvest.java +++ b/src/main/java/io/github/_5thlayer/voidworks/siphon/SiphonHarvest.java @@ -13,9 +13,8 @@ import io.github._5thlayer.voidworks.energy.VoidEnergy; import io.github._5thlayer.voidworks.pressure.HarvestKind; import io.github._5thlayer.voidworks.pressure.VoidPressure; +import io.github._5thlayer.voidworks.taking.VoidTaking; import net.minecraft.core.BlockPos; -import net.minecraft.core.particles.BlockParticleOption; -import net.minecraft.core.particles.ParticleTypes; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; import net.minecraft.sounds.SoundEvents; @@ -42,7 +41,8 @@ * One harvest by hand: the Void Siphon in the main hand, a block of Density above 0 in the off hand, * aimed like placing a block. It behaves by the dimension's harvest kind, yields one Density step * below a Displacer ({@link SiphonMotes#handYield}) at one grade above the Void Pressure where it - * harvests, credits the Siphon, consumes the fed block, and leaves Voidstone where it harvested: + * harvests, credits the Siphon, consumes the fed block, and leaves Voidstone where it harvested, the + * void seen taking what was there ({@link VoidTaking}): * *
    *
  • {@code end}: the fed block goes into the air aimed at and becomes Voidstone;
  • @@ -130,9 +130,8 @@ public static Outcome harvest(ServerLevel level, Player player, BlockHitResult h if (!natural.isEmpty()) { player.getInventory().placeItemBackInInventory(natural); } - level.sendParticles(new BlockParticleOption(ParticleTypes.BLOCK, harvested), - target.getX() + 0.5, target.getY() + 0.5, target.getZ() + 0.5, 30, 0.3, 0.3, 0.3, 0.05); } + VoidTaking.show(level, target, harvested); MoteFlight.toEntity(level, target.getCenter(), player, grade, motes); level.playSound(null, target, SoundEvents.CHORUS_FRUIT_TELEPORT, SoundSource.PLAYERS, 0.6F, 0.9F + level.getRandom().nextFloat() * 0.2F); return Outcome.HARVESTED; diff --git a/src/main/java/io/github/_5thlayer/voidworks/taking/TakingMask.java b/src/main/java/io/github/_5thlayer/voidworks/taking/TakingMask.java new file mode 100644 index 0000000..44de11d --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/taking/TakingMask.java @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.github._5thlayer.voidworks.Voidworks; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; + +/** + * The masks a face is taken through, after Just Dire Things' goo: one per stage, {@link #SIZE} + * pixels square, each painting what the void has reached by its stage. A pixel is reached from the + * first stage whose mask paints it and stays reached, and the last stage reaches every pixel, so a + * mask need not repeat the ones before it. What of a face is left is read as runs along its rows, + * the masks turned one of {@link #ORIENTATIONS} ways by the face's position so neighbours differ. + */ +public final class TakingMask { + + /** A mask's width and height, in pixels: a block texture's. */ + public static final int SIZE = 16; + + /** The ways a face turns its masks: four turns, each mirrored or not. */ + public static final int ORIENTATIONS = 8; + + /** What of a face's row {@code y} is left, from pixel {@code x0} up to but not including {@code x1}. */ + public record Run(int y, int x0, int x1) { + } + + /** For each pixel, row by row, the stage that reaches it, from 1 to {@link TakingStages#STAGES}. */ + private final byte[] reachedAt; + + private TakingMask(byte[] reachedAt) { + this.reachedAt = reachedAt; + } + + /** + * @param masks one per stage, in order, each {@link #SIZE} squared pixels row by row, true where it paints + * @throws IllegalArgumentException when there are not {@link TakingStages#STAGES} masks, or one is not {@link #SIZE} square + */ + public static TakingMask of(List masks) { + if (masks.size() != TakingStages.STAGES) { + throw new IllegalArgumentException("a face is taken through " + TakingStages.STAGES + " masks, not " + masks.size()); + } + var reachedAt = new byte[SIZE * SIZE]; + Arrays.fill(reachedAt, (byte) TakingStages.STAGES); + for (int stage = masks.size(); stage >= 1; stage--) { + var mask = masks.get(stage - 1); + if (mask.length != SIZE * SIZE) { + throw new IllegalArgumentException("mask " + stage + " has " + mask.length + " pixels, not " + SIZE * SIZE); + } + for (int pixel = 0; pixel < mask.length; pixel++) { + if (mask[pixel]) { + reachedAt[pixel] = (byte) stage; + } + } + } + return new TakingMask(reachedAt); + } + + /** @return where the mask of {@code stage}, from 1 to {@link TakingStages#STAGES}, is among the client's resources */ + public static Identifier path(int stage) { + return Identifier.fromNamespaceAndPath(Voidworks.MOD_ID, "textures/void_taking/stage_" + stage + ".png"); + } + + /** @return whether a mask's pixel of colour {@code argb} paints: whether it is at least half opaque */ + public static boolean paints(int argb) { + return (argb >>> 24) >= 0x80; + } + + /** @return the way the face of the block at {@code pos} towards {@code face} turns its masks, from 0 to {@link #ORIENTATIONS} */ + public static int orientation(BlockPos pos, Direction face) { + // SplitMix64's finaliser, so that neighbouring positions scatter. + long hash = pos.asLong() * Direction.values().length + face.get3DDataValue(); + hash = (hash ^ (hash >>> 30)) * 0xBF58476D1CE4E5B9L; + hash = (hash ^ (hash >>> 27)) * 0x94D049BB133111EBL; + hash ^= hash >>> 31; + return (int) (hash & (ORIENTATIONS - 1)); + } + + /** + * @param reached how many stages the void has reached, from 0 to {@link TakingStages#STAGES} + * @param orientation how the face turns the masks, from {@link #orientation} + * @return what of the face the void has not reached, as runs along its rows, top row first + */ + public List left(int reached, int orientation) { + var runs = new ArrayList(); + for (int y = 0; y < SIZE; y++) { + int start = -1; + for (int x = 0; x <= SIZE; x++) { + boolean left = x < SIZE && reachedAt[turned(x, y, orientation)] > reached; + if (left && start < 0) { + start = x; + } else if (!left && start >= 0) { + runs.add(new Run(y, start, x)); + start = -1; + } + } + } + return runs; + } + + /** @return the mask's pixel that the face's pixel at {@code x}, {@code y} shows, turned by {@code orientation} */ + private static int turned(int x, int y, int orientation) { + if ((orientation & 1) != 0) { + x = SIZE - 1 - x; + } + if ((orientation & 2) != 0) { + y = SIZE - 1 - y; + } + if ((orientation & 4) != 0) { + int swap = x; + x = y; + y = swap; + } + return y * SIZE + x; + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/taking/TakingStages.java b/src/main/java/io/github/_5thlayer/voidworks/taking/TakingStages.java new file mode 100644 index 0000000..34770ae --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/taking/TakingStages.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +/** + * How far the void has taken a block, by the time since it started: through {@link #STAGES} stages + * over {@link #DURATION} ticks, evenly, none reached as it starts and every one by its end. + */ +public final class TakingStages { + + /** How long the void takes over a block, in ticks: a Displacer's harvest interval, so its growth reads as a wave. */ + public static final int DURATION = 20; + + /** The stages it goes through, one mask each. */ + public static final int STAGES = 12; + + private TakingStages() { + } + + /** @return how many stages the void has reached {@code elapsed} ticks after it started, from 0 to {@link #STAGES} */ + public static int reached(float elapsed) { + return Math.clamp((int) Math.floor(elapsed * STAGES / DURATION), 0, STAGES); + } + + /** @return whether the void has taken the whole block {@code elapsed} ticks after it started */ + public static boolean over(float elapsed) { + return elapsed >= DURATION; + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/taking/VoidTakesBlockEvent.java b/src/main/java/io/github/_5thlayer/voidworks/taking/VoidTakesBlockEvent.java new file mode 100644 index 0000000..14e8417 --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/taking/VoidTakesBlockEvent.java @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.bus.api.Event; + +/** + * The void took a block: a Void Displacer or a Void Siphon harvested {@link #pos}, or a spent + * Displacer there became Voidstone. It is posted on the NeoForge event bus, on the server, once the + * position is Voidstone, so {@link #taken} is what it held before: the harvested block, lava or air, + * or the Displacer. Each client tracking the position is then shown the void taking it. + */ +public final class VoidTakesBlockEvent extends Event { + + private final ServerLevel level; + private final BlockPos pos; + private final BlockState taken; + + public VoidTakesBlockEvent(ServerLevel level, BlockPos pos, BlockState taken) { + this.level = level; + this.pos = pos.immutable(); + this.taken = taken; + } + + public ServerLevel level() { + return level; + } + + /** @return the position the void took, Voidstone now */ + public BlockPos pos() { + return pos; + } + + /** @return what the position held before the void took it */ + public BlockState taken() { + return taken; + } +} diff --git a/src/main/java/io/github/_5thlayer/voidworks/taking/VoidTaking.java b/src/main/java/io/github/_5thlayer/voidworks/taking/VoidTaking.java new file mode 100644 index 0000000..4fa2444 --- /dev/null +++ b/src/main/java/io/github/_5thlayer/voidworks/taking/VoidTaking.java @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +import io.github._5thlayer.voidworks.Voidworks; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; + +/** + * The void seen taking a block: {@code pos}, Voidstone since game time {@code start}, held + * {@code taken}, which each client draws over the Voidstone and eats away through + * {@link TakingStages}. It is only a look, like the butterflies: the position is Voidstone for + * everything from the start. + * + *

    Whatever leaves Voidstone where it harvested calls {@link #show} on the server once it is there. + */ +public record VoidTaking(BlockPos pos, BlockState taken, long start) implements CustomPacketPayload { + + public static final Type TYPE = new Type<>(Identifier.fromNamespaceAndPath(Voidworks.MOD_ID, "void_taking")); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + BlockPos.STREAM_CODEC, VoidTaking::pos, + ByteBufCodecs.idMapper(Block.BLOCK_STATE_REGISTRY), VoidTaking::taken, + ByteBufCodecs.VAR_LONG, VoidTaking::start, + VoidTaking::new); + + public static void register(IEventBus modBus) { + // Optional, so a client without Voidworks can still join; it sees the Voidstone at once. + modBus.addListener((RegisterPayloadHandlersEvent event) -> event.registrar("1").optional().playToClient(TYPE, STREAM_CODEC)); + } + + /** + * Shows the void taking {@code pos}, Voidstone now, which held {@code taken}: it posts a + * {@link VoidTakesBlockEvent}, then sends the taking to the players tracking the position. + */ + public static void show(ServerLevel level, BlockPos pos, BlockState taken) { + var event = NeoForge.EVENT_BUS.post(new VoidTakesBlockEvent(level, pos, taken)); + PacketDistributor.sendToPlayersTrackingChunk(level, ChunkPos.containing(event.pos()), + new VoidTaking(event.pos(), taken, level.getGameTime())); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_1.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_1.png new file mode 100644 index 0000000000000000000000000000000000000000..e5c916c050990419930d87cb51b0bf42f05c5c07 GIT binary patch literal 128 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`?w&4=Ar*6yQyiH7@ND`&@u$3` zoUxGrgHMY{Pg9_Sc!TjuCH3qYPbp>1&&DVIJx`zT*WbtPz$>Pt++#0+gc`?%&1QNG Z3`W-lOufZQUjU6{@O1TaS?83{1OT`%CzSvI literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_10.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_10.png new file mode 100644 index 0000000000000000000000000000000000000000..cb6650d54761bfe5b31d366faf438605ab16101a GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Zk{fVAr*6ypQKOtd)`6m$B7R! z1dpXDg&JE)u(53rSS?`rU;?8uGZ3sabi5m(pf0QMTJbikf%6G71~ES88>|KfAArWC X{1NcU@R?){G?Bs6)z4*}Q$iB}aUUc> literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_11.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_11.png new file mode 100644 index 0000000000000000000000000000000000000000..4dc65e180c605d4b50bcb4c21cc68373bfbf86d8 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`E}kxqAr*6ypQKOtd)|TV$B7R! zCN=^=r_9+C2}`6HxE(g}I`mm?+RI>^lE6H1zT>z52X;MBKAe0^an^(tX$m;NgnD0n#bVj>gTe~DWM4fqg5=3 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_12.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_12.png new file mode 100644 index 0000000000000000000000000000000000000000..ba2a3bedffae1e14a08de979a8b7568c1e685a91 GIT binary patch literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`vYsxEAr*6ypQKOtd)|RDVUf${ j6^x77dZY|y@Gvm^pT+1MWyfU$RLJ1z>gTe~DWM4fVS5(V literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_2.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_2.png new file mode 100644 index 0000000000000000000000000000000000000000..ab3004ebbc6b730bbdf57fdbc90058f089c80803 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`fu1goAr*6yQyQ4+*lzyki8$ci zcD(1tk;I11h*lO}h2!4>4PG#=YiZ^Cu#6d`qJ`&>hsF}MOc8N`*Ew_89&BQbpL9%` jt;OUq%Mlhi9tMV-nPOHv$M1dw8qVP9>gTe~DWM4fLZU77 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_3.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_3.png new file mode 100644 index 0000000000000000000000000000000000000000..078c1ea3097405fed62d5b0494e58f282e019688 GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k)AG&Ar*6yQyQ4+*lzyksW{-? zcD(1tk(nPdm6ptGY;0t7Wx6Z+!{iamAD&JBIV;me0wfc9)q)lYD=Q{;JJnn~<+8>p trpQG#wT2;&VdqMI(TC3<(59OgZs>giz|sHc!(!M#&zBU2J{aRDPwebQ&PiyIrXc|NrADjRlv z*xB}CO2=s>wV<5iY#wEdyTm*AnD@-&oX_M`#BgD*!i;qDxxGNk7(8A5T-G@yGywok C88g@b literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_6.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_6.png new file mode 100644 index 0000000000000000000000000000000000000000..1d3b174b227552ec4398163a5982ba0a26e37e68 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`ah@)YAr*6yQxXyq9;8qBd)|TN z$B7R!1Y>(TC3=4_aPKr*yn|srlT*V2aSx+8^Buq0Z)CKpVCec^U@0GOaX{7MqxGgn zpCwst>>|ru)Vvn=7Ku-o)pPNxuqgKr9Y%)gW0LPU!ZSjFHZgd*`njxgN@xNA2wF3f literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_7.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_7.png new file mode 100644 index 0000000000000000000000000000000000000000..289cba061bbba6a8de026e8c3072076834db55a8 GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`>7Fi*Ar*73PTI|TK!L|aUd&c_ z!~cYt3c?fkT2GYa1UK$rn)G>CN=MaeJ}w9>GwP zUnfkMHtdK<(LWpGGRJgbNbfCi)mM3r`zE9})pNIGeR=nQ$jBq)X}cX1c&WSZ}`M^C{`xedr}n$TFWw}CsrRMue9 sWTA<%h95L1>M*a6?%?5>@|2aqN=$0*vnywqfwnMsy85}Sb4q9e0F(|dRR910 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/voidworks/textures/void_taking/stage_9.png b/src/main/resources/assets/voidworks/textures/void_taking/stage_9.png new file mode 100644 index 0000000000000000000000000000000000000000..5d9d6a090ee961fd715aecbdb4ddedef0011c359 GIT binary patch literal 140 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`!JaOTAr*6ypQKOtd)`6m$B7R! z1dpXDg&JE)R4_|ON=Qg_aI&Y=D>I7~nI2*&-E-i8!yNMj24$&}p4=L26SyYmrYt&A nwkg)&43C19=3$1aQ!ETcHiD&$>_MzR;~6|%{an^LB{Ts5gBmDx literal 0 HcmV?d00001 diff --git a/src/test/java/io/github/_5thlayer/voidworks/taking/ShippedMasksTest.java b/src/test/java/io/github/_5thlayer/voidworks/taking/ShippedMasksTest.java new file mode 100644 index 0000000..09840f9 --- /dev/null +++ b/src/test/java/io/github/_5thlayer/voidworks/taking/ShippedMasksTest.java @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.Test; + +/** + * The 12 masks Voidworks ships ({@link TakingMask#path}): each a block texture's size, each painting + * all the one before it painted and more, so the void only ever grows, and the last painting the + * whole face. + */ +class ShippedMasksTest { + + @Test + void eachMaskIsSixteenPixelsSquare() throws IOException { + for (int stage = 1; stage <= TakingStages.STAGES; stage++) { + var image = load(stage); + assertEquals(TakingMask.SIZE, image.getWidth(), "stage " + stage); + assertEquals(TakingMask.SIZE, image.getHeight(), "stage " + stage); + } + } + + @Test + void eachMaskPaintsMoreThanTheOneBefore() throws IOException { + var masks = painted(); + for (int stage = 1; stage < masks.size(); stage++) { + int before = count(masks.get(stage - 1)); + int now = count(masks.get(stage)); + assertTrue(now > before, "stage " + (stage + 1) + " paints " + now + " pixels, stage " + stage + " " + before); + for (int pixel = 0; pixel < now; pixel++) { + assertTrue(!masks.get(stage - 1)[pixel] || masks.get(stage)[pixel], + "stage " + (stage + 1) + " leaves out pixel " + pixel + ", which stage " + stage + " paints"); + } + } + } + + @Test + void theLastMaskPaintsTheWholeFace() throws IOException { + assertEquals(TakingMask.SIZE * TakingMask.SIZE, count(painted().getLast())); + } + + private static List painted() throws IOException { + var masks = new ArrayList(); + for (int stage = 1; stage <= TakingStages.STAGES; stage++) { + var image = load(stage); + var mask = new boolean[TakingMask.SIZE * TakingMask.SIZE]; + for (int y = 0; y < TakingMask.SIZE; y++) { + for (int x = 0; x < TakingMask.SIZE; x++) { + mask[y * TakingMask.SIZE + x] = TakingMask.paints(image.getRGB(x, y)); + } + } + masks.add(mask); + } + return masks; + } + + private static BufferedImage load(int stage) throws IOException { + var path = "/assets/" + TakingMask.path(stage).getNamespace() + "/" + TakingMask.path(stage).getPath(); + try (var in = ShippedMasksTest.class.getResourceAsStream(path)) { + assertNotNull(in, "no mask at " + path); + return ImageIO.read(in); + } + } + + private static int count(boolean[] mask) { + int count = 0; + for (boolean paints : mask) { + count += paints ? 1 : 0; + } + return count; + } +} diff --git a/src/test/java/io/github/_5thlayer/voidworks/taking/TakingMaskTest.java b/src/test/java/io/github/_5thlayer/voidworks/taking/TakingMaskTest.java new file mode 100644 index 0000000..0089e97 --- /dev/null +++ b/src/test/java/io/github/_5thlayer/voidworks/taking/TakingMaskTest.java @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import io.github._5thlayer.voidworks.taking.TakingMask.Run; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import org.junit.jupiter.api.Test; + +/** + * The 12 masks a face is taken through, read as what of the face the void has not yet reached: a + * pixel a stage's mask paints is reached from that stage on, every pixel by the last, and each face + * turns the masks by its position. + */ +class TakingMaskTest { + + private static final int SIZE = TakingMask.SIZE; + + @Test + void withNoStageReachedTheWholeFaceIsLeftRowByRow() { + var mask = TakingMask.of(masks(List.of())); + var expected = new ArrayList(); + for (int y = 0; y < SIZE; y++) { + expected.add(new Run(y, 0, SIZE)); + } + assertEquals(expected, mask.left(0, 0)); + } + + @Test + void aPixelAStagePaintsIsReachedFromThatStageOn() { + var mask = TakingMask.of(masks(List.of(Set.of(pixel(3, 5))))); + assertTrue(mask.left(0, 0).contains(new Run(5, 0, SIZE))); + var left = mask.left(1, 0); + assertTrue(left.contains(new Run(5, 0, 3)) && left.contains(new Run(5, 4, SIZE)), "row 5 left " + left); + assertEquals(SIZE * SIZE - 1, area(left)); + } + + @Test + void aReachedPixelStaysReachedThoughALaterMaskLeavesItOut() { + var mask = TakingMask.of(masks(List.of(Set.of(pixel(3, 5)), Set.of(pixel(9, 9))))); + var left = mask.left(2, 0); + assertEquals(SIZE * SIZE - 2, area(left)); + assertFalse(covers(left, 3, 5)); + assertFalse(covers(left, 9, 9)); + } + + @Test + void theLastStageReachesEveryPixelThoughItsMaskLeavesSomeOut() { + var mask = TakingMask.of(masks(List.of(Set.of(pixel(3, 5))))); + assertEquals(List.of(), mask.left(TakingStages.STAGES, 0)); + } + + @Test + void theEightOrientationsTurnAPixelToEightPlaces() { + var mask = TakingMask.of(masks(List.of(Set.of(pixel(3, 5))))); + var places = new HashSet(); + for (int orientation = 0; orientation < TakingMask.ORIENTATIONS; orientation++) { + var left = mask.left(1, orientation); + assertEquals(SIZE * SIZE - 1, area(left), "orientation " + orientation); + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) { + if (!covers(left, x, y)) { + places.add(pixel(x, y)); + } + } + } + } + assertEquals(TakingMask.ORIENTATIONS, places.size(), "the reached pixel landed on " + places); + } + + @Test + void theUnturnedOrientationLeavesTheMaskAsDrawn() { + var mask = TakingMask.of(masks(List.of(Set.of(pixel(3, 5))))); + assertFalse(covers(mask.left(1, 0), 3, 5)); + } + + @Test + void aFaceIsTurnedTheSameWayEveryTime() { + var pos = new BlockPos(12, -40, 7); + assertEquals(TakingMask.orientation(pos, Direction.NORTH), TakingMask.orientation(pos, Direction.NORTH)); + } + + @Test + void neighbouringFacesAreTurnedEveryWay() { + var seen = new HashSet(); + for (var pos : BlockPos.betweenClosed(0, 0, 0, 2, 2, 2)) { + for (var face : Direction.values()) { + int orientation = TakingMask.orientation(pos, face); + assertTrue(orientation >= 0 && orientation < TakingMask.ORIENTATIONS, "orientation " + orientation); + seen.add(orientation); + } + } + assertEquals(TakingMask.ORIENTATIONS, seen.size(), "the faces of 27 blocks were turned only " + seen); + } + + @Test + void anOpaquePixelPaintsAndATransparentOneDoesNot() { + assertTrue(TakingMask.paints(0xFF_2A_12_45)); + assertTrue(TakingMask.paints(0x80_FF_FF_FF)); + assertFalse(TakingMask.paints(0x7F_00_00_00)); + assertFalse(TakingMask.paints(0x00_FF_FF_FF)); + } + + @Test + void anythingButTwelveMasksIsRefused() { + var eleven = masks(List.of()).subList(0, TakingStages.STAGES - 1); + assertThrows(IllegalArgumentException.class, () -> TakingMask.of(eleven)); + } + + @Test + void aMaskOfTheWrongSizeIsRefused() { + var masks = new ArrayList<>(masks(List.of())); + masks.set(4, new boolean[SIZE * SIZE - 1]); + assertThrows(IllegalArgumentException.class, () -> TakingMask.of(masks)); + } + + /** @return 12 masks, the first ones painting the pixels given and the rest nothing */ + private static List masks(List> painted) { + var masks = new ArrayList(); + for (int stage = 0; stage < TakingStages.STAGES; stage++) { + var mask = new boolean[SIZE * SIZE]; + if (stage < painted.size()) { + painted.get(stage).forEach(index -> mask[index] = true); + } + masks.add(mask); + } + return masks; + } + + private static int pixel(int x, int y) { + return y * SIZE + x; + } + + private static int area(List runs) { + return runs.stream().mapToInt(run -> run.x1() - run.x0()).sum(); + } + + private static boolean covers(List runs, int x, int y) { + return runs.stream().anyMatch(run -> run.y() == y && run.x0() <= x && x < run.x1()); + } +} diff --git a/src/test/java/io/github/_5thlayer/voidworks/taking/TakingStagesTest.java b/src/test/java/io/github/_5thlayer/voidworks/taking/TakingStagesTest.java new file mode 100644 index 0000000..05ac86b --- /dev/null +++ b/src/test/java/io/github/_5thlayer/voidworks/taking/TakingStagesTest.java @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 5thlayer +// SPDX-License-Identifier: MIT + +package io.github._5thlayer.voidworks.taking; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The void takes a block through 12 stages over 20 ticks: none reached as it starts, all 12 by its end. */ +class TakingStagesTest { + + @Test + void asItStartsTheVoidHasReachedNoStage() { + assertEquals(0, TakingStages.reached(0)); + } + + @Test + void halfwayItHasReachedHalfTheStages() { + assertEquals(6, TakingStages.reached(10)); + } + + @Test + void aStageIsReachedOnlyOnceItsShareOfTheTimeHasPassed() { + // 20 ticks over 12 stages: the first is reached at 1⅔ ticks. + assertEquals(0, TakingStages.reached(1.6F)); + assertEquals(1, TakingStages.reached(1.7F)); + } + + @Test + void justBeforeItsEndElevenStagesAreReached() { + assertEquals(11, TakingStages.reached(19.99F)); + } + + @Test + void stagesAreNeverUndone() { + int last = 0; + for (float elapsed = 0; elapsed < 25; elapsed += 0.05F) { + int reached = TakingStages.reached(elapsed); + assertTrue(reached >= last, "at " + elapsed + " ticks it reached " + reached + ", after " + last); + last = reached; + } + } + + @Test + void afterItsEndEveryStageIsReached() { + assertEquals(12, TakingStages.reached(20)); + assertEquals(12, TakingStages.reached(500)); + } + + /** The client's clock can trail the server's that stamped its start. */ + @Test + void aTakingSeenBeforeItsStartHasReachedNoStage() { + assertEquals(0, TakingStages.reached(-3)); + } + + @Test + void itIsOverOnlyOnceTwentyTicksHavePassed() { + assertFalse(TakingStages.over(19.99F)); + assertTrue(TakingStages.over(20)); + } +} From e10e6766ca87a4b40b42c39c23d5957e8c9ed844 Mon Sep 17 00:00:00 2001 From: Andrea D'Amico Date: Fri, 16 Oct 2026 19:35:16 +0200 Subject: [PATCH 3/3] fix: keep the void's taking in its level and off the voidstone's faces From the review: a taking is dropped when the player leaves its level, a glowing solid keeps its glow while taken, crumbs are shed from its open faces rather than inside the Voidstone, a fluid's or air's cube shows every face no opaque block covers so none of a lake's Voidstone shows early, and it is drawn half a percent larger a side so depth tells it from the Voidstone a chunk away. Co-Authored-By: Claude Opus 5.5 --- .../voidworks/client/VoidTakings.java | 171 +++++++++++------- 1 file changed, 101 insertions(+), 70 deletions(-) diff --git a/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java b/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java index eb644d5..1b83acc 100644 --- a/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java +++ b/src/main/java/io/github/_5thlayer/voidworks/client/VoidTakings.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; import com.mojang.blaze3d.platform.NativeImage; @@ -16,14 +17,15 @@ import io.github._5thlayer.voidworks.taking.TakingStages; import io.github._5thlayer.voidworks.taking.VoidTaking; import net.minecraft.client.Minecraft; +import net.minecraft.client.model.geom.builders.UVPair; import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.FaceInfo; import net.minecraft.client.renderer.LevelRenderer; import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.client.renderer.rendertype.RenderTypes; import net.minecraft.client.renderer.texture.TextureAtlasSprite; -import net.minecraft.client.model.geom.builders.UVPair; -import net.minecraft.client.renderer.FaceInfo; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.particles.BlockParticleOption; @@ -57,9 +59,9 @@ * The void seen taking blocks ({@link VoidTaking}): each block it takes is drawn over the Voidstone * now there, a hair larger, and eaten away through the {@link TakingMask} over * {@link TakingStages#DURATION} ticks, until only the Voidstone is left. A solid block is drawn as its - * own model and sheds crumbs as it goes, a fluid as a full cube of its still texture, glowing if it - * glows, and air as the End's starfield. A taking whose chunk unloads is dropped, and nothing of it - * outlives the session. + * own model and sheds crumbs from its open faces as it goes, a fluid as a full cube of its still + * texture, glowing if it glows, and air as the End's starfield. A taking whose chunk unloads, or + * whose level the player leaves, is dropped, and nothing of it outlives the session. */ final class VoidTakings { @@ -70,14 +72,23 @@ final class VoidTakings { private static final Vector3fc BLOCK_FROM = new Vector3f(0, 0, 0); private static final Vector3fc BLOCK_TO = new Vector3f(1, 1, 1); - /** How much larger than the block it is drawn, so it never fights the Voidstone's faces. */ - private static final float GROWN = 1.002F; + /** + * How much larger than the block it is drawn, so it never fights the Voidstone's faces: half a + * percent a side, which depth still tells apart from the Voidstone a chunk or more away. + */ + private static final float OVERSIZE = 1.01F; + + /** The faces a model's quads are culled by, and null for those never culled. */ + private static final @Nullable Direction[] CULLED = {null, Direction.DOWN, Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; /** The crumbs a solid block sheds while it is taken: as many as it once burst into, spread over its stages. */ private static final float CRUMBS_PER_TICK = 30F / TakingStages.DURATION; private static final List SHOWN = new ArrayList<>(); + /** The level {@link #SHOWN} is in: a taking carries no dimension, so leaving the level drops them. */ + private static @Nullable ClientLevel shownIn; + /** The masks, once the client's resources have loaded them; nothing is drawn without them. */ private static @Nullable TakingMask mask; @@ -100,7 +111,18 @@ static void register(IEventBus modBus) { } static void receive(VoidTaking taking) { - SHOWN.add(taking); + if (keepTo(Minecraft.getInstance().level)) { + SHOWN.add(taking); + } + } + + /** Drops what was shown in another level than {@code level}; @return whether there is a level to show in */ + private static boolean keepTo(@Nullable ClientLevel level) { + if (level != shownIn) { + SHOWN.clear(); + shownIn = level; + } + return level != null; } private static void loadMasks(ResourceManager resources) { @@ -129,29 +151,37 @@ private static void loadMasks(ResourceManager resources) { private static void tick() { var minecraft = Minecraft.getInstance(); var level = minecraft.level; - if (level == null) { - SHOWN.clear(); - return; - } - if (minecraft.isPaused()) { + if (!keepTo(level) || minecraft.isPaused()) { return; } long now = level.getGameTime(); SHOWN.removeIf(taking -> TakingStages.over(now - taking.start())); var random = level.getRandom(); for (var taking : SHOWN) { - if (solid(taking.taken())) { - int crumbs = (int) CRUMBS_PER_TICK + (random.nextFloat() < CRUMBS_PER_TICK % 1 ? 1 : 0); - for (int i = 0; i < crumbs; i++) { - var at = Vec3.atCenterOf(taking.pos()).add(random.nextFloat() - 0.5, random.nextFloat() - 0.5, random.nextFloat() - 0.5); - level.addParticle(new BlockParticleOption(ParticleTypes.BLOCK, taking.taken()), at.x, at.y, at.z, 0, 0, 0); + var taken = taking.taken(); + if (taken.isAir() || !taken.getFluidState().isEmpty()) { + continue; + } + int crumbs = (int) CRUMBS_PER_TICK + (random.nextFloat() < CRUMBS_PER_TICK % 1 ? 1 : 0); + for (int i = 0; i < crumbs; i++) { + // Off one of its faces still open, as the Voidstone under it hides any shed inside. + var face = Direction.getRandom(random); + if (covered(level, taking.pos(), face)) { + continue; } + var normal = face.getUnitVec3(); + var at = Vec3.atCenterOf(taking.pos()).add(normal.scale(0.55)) + .add(new Vec3(random.nextFloat() - 0.5, random.nextFloat() - 0.5, random.nextFloat() - 0.5).multiply(1 - Math.abs(normal.x), 1 - Math.abs(normal.y), 1 - Math.abs(normal.z))); + level.addParticle(new BlockParticleOption(ParticleTypes.BLOCK, taken), at.x, at.y, at.z, 0, 0, 0); } } } - /** One face of what is being taken, drawn in {@link #type} over the runs of it the void has not reached. */ - private record Face(RenderType type, Vector3fc[] corners, float[] uvs, int colour, int light, List left) { + /** + * One face of what is being taken, drawn in {@link #type} over the runs of it the void has not + * reached; {@link #uvs}, {@link #colour} and {@link #light} only when it is {@link #textured}. + */ + private record Face(RenderType type, boolean textured, Vector3fc[] corners, float[] uvs, int colour, int light, List left) { } /** What one taking draws this frame, its faces around {@link #pos}. */ @@ -181,24 +211,15 @@ private static List faces(ClientLevel level, VoidTaking taking, TakingMask var faces = new ArrayList(); var pos = taking.pos(); var taken = taking.taken(); - if (taken.isAir()) { - for (var direction : Direction.values()) { - if (shown(level, pos, taken, direction)) { - faces.add(cubeFace(RenderTypes.endPortal(), direction, null, -1, 0, mask.left(reached, TakingMask.orientation(pos, direction)))); - } - } - } else if (!taken.getFluidState().isEmpty()) { - var fluid = taken.getFluidState(); - var model = Minecraft.getInstance().getModelManager().getFluidStateModelSet().get(fluid); - int tint = model.fluidTintSource() != null ? model.fluidTintSource().colorInWorld(fluid, taken, level, pos) : -1; - boolean glows = taken.getLightEmission(level, pos) > 0; - var type = model.layer().translucent() ? RenderTypes.translucentMovingBlock() : RenderTypes.cutoutMovingBlock(); + var left = new EnumMap>(Direction.class); + for (var direction : Direction.values()) { + left.put(direction, mask.left(reached, TakingMask.orientation(pos, direction))); + } + if (taken.isAir() || !taken.getFluidState().isEmpty()) { + // A full cube, whatever fills the space beside it, so none of the Voidstone shows early. for (var direction : Direction.values()) { - if (shown(level, pos, taken, direction)) { - int colour = glows ? tint : ARGB.scaleRGB(tint, level.cardinalLighting().byFace(direction)); - int light = glows ? LightCoordsUtil.FULL_BRIGHT : LevelRenderer.getLightCoords(level, pos.relative(direction)); - faces.add(cubeFace(type, direction, model.stillMaterial().sprite(), colour, light, - mask.left(reached, TakingMask.orientation(pos, direction)))); + if (!covered(level, pos, direction)) { + faces.add(taken.isAir() ? starfield(direction, left.get(direction)) : fluidFace(level, pos, taken, direction, left.get(direction))); } } } else { @@ -206,9 +227,10 @@ private static List faces(ClientLevel level, VoidTaking taking, TakingMask var parts = new ArrayList(); model.collectParts(level, pos, taken, RandomSource.create(taken.getSeed(pos)), parts); var colours = Minecraft.getInstance().getBlockColors(); + int emission = taken.getLightEmission(level, pos); for (var part : parts) { for (var culled : CULLED) { - if (culled != null && !shown(level, pos, taken, culled)) { + if (culled != null && !Block.shouldRenderFace(taken, level.getBlockState(pos.relative(culled)), culled)) { continue; } for (var quad : part.getQuads(culled)) { @@ -229,9 +251,8 @@ private static List faces(ClientLevel level, VoidTaking taking, TakingMask uvs[2 * i] = UVPair.unpackU(quad.packedUV(i)); uvs[2 * i + 1] = UVPair.unpackV(quad.packedUV(i)); } - var type = material.layer().translucent() ? RenderTypes.translucentMovingBlock() : RenderTypes.cutoutMovingBlock(); - faces.add(new Face(type, corners, uvs, colour, LevelRenderer.getLightCoords(level, pos.relative(direction)), - mask.left(reached, TakingMask.orientation(pos, direction)))); + int light = LightCoordsUtil.lightCoordsWithEmission(LevelRenderer.getLightCoords(level, pos.relative(direction)), emission); + faces.add(new Face(type(material.layer()), true, corners, uvs, colour, light, left.get(direction))); } } } @@ -240,27 +261,42 @@ private static List faces(ClientLevel level, VoidTaking taking, TakingMask return faces; } - /** The faces a model's quads are culled by, and null for those never culled. */ - private static final @Nullable Direction[] CULLED = {null, Direction.DOWN, Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; + /** @return the fluid's face towards {@code direction}: its still texture, tinted, and bright when it glows */ + private static Face fluidFace(ClientLevel level, BlockPos pos, BlockState taken, Direction direction, List left) { + var fluid = taken.getFluidState(); + var model = Minecraft.getInstance().getModelManager().getFluidStateModelSet().get(fluid); + int tint = model.fluidTintSource() != null ? model.fluidTintSource().colorInWorld(fluid, taken, level, pos) : -1; + boolean glows = taken.getLightEmission(level, pos) > 0; + int colour = glows ? tint : ARGB.scaleRGB(tint, level.cardinalLighting().byFace(direction)); + int light = glows ? LightCoordsUtil.FULL_BRIGHT : LevelRenderer.getLightCoords(level, pos.relative(direction)); + var sprite = model.stillMaterial().sprite(); + var uvs = new float[] {sprite.getU0(), sprite.getV0(), sprite.getU0(), sprite.getV1(), sprite.getU1(), sprite.getV1(), sprite.getU1(), sprite.getV0()}; + return new Face(type(model.layer()), true, cubeCorners(direction), uvs, colour, light, left); + } - /** @return whether the face of {@code taken} at {@code pos} towards {@code direction} would show, were it still there */ - private static boolean shown(ClientLevel level, BlockPos pos, BlockState taken, Direction direction) { - return Block.shouldRenderFace(taken, level.getBlockState(pos.relative(direction)), direction); + /** @return the face towards {@code direction} of the End's starfield, which takes no texture, colour or light */ + private static Face starfield(Direction direction, List left) { + return new Face(RenderTypes.endPortal(), false, cubeCorners(direction), new float[8], -1, 0, left); } - /** - * The block's face towards {@code direction}, corners in the order a model's face has them, with - * {@code sprite} over it whole, or no texture for a render type that takes none. - */ - private static Face cubeFace(RenderType type, Direction direction, @Nullable TextureAtlasSprite sprite, int colour, int light, List left) { + /** @return whether the block beside {@code pos} towards {@code direction} hides that face of it whole */ + private static boolean covered(ClientLevel level, BlockPos pos, Direction direction) { + return level.getBlockState(pos.relative(direction)).isSolidRender(); + } + + /** @return the block's face towards {@code direction}, its corners in the order a model's face has them */ + private static Vector3fc[] cubeCorners(Direction direction) { var info = FaceInfo.fromFacing(direction); var corners = new Vector3fc[4]; for (int i = 0; i < 4; i++) { corners[i] = info.getVertexInfo(i).select(BLOCK_FROM, BLOCK_TO); } - var uvs = sprite == null ? new float[8] : new float[] { - sprite.getU0(), sprite.getV0(), sprite.getU0(), sprite.getV1(), sprite.getU1(), sprite.getV1(), sprite.getU1(), sprite.getV0()}; - return new Face(type, corners, uvs, colour, light, left); + return corners; + } + + /** @return the moving-block render type of a quad's or fluid's layer, as a piston draws a block it moves */ + private static RenderType type(ChunkSectionLayer layer) { + return layer.translucent() ? RenderTypes.translucentMovingBlock() : RenderTypes.cutoutMovingBlock(); } private static void submit(SubmitCustomGeometryEvent event) { @@ -282,17 +318,16 @@ private static void submit(SubmitCustomGeometryEvent event) { /** Draws each run of the face the void has not reached, as the part of the face over those pixels. */ private static void draw(PoseStack.Pose pose, VertexConsumer buffer, Face face) { - boolean textured = face.type() != RenderTypes.endPortal(); float size = TakingMask.SIZE; for (var run : face.left()) { float s0 = run.x0() / size; float s1 = run.x1() / size; float t0 = run.y() / size; float t1 = (run.y() + 1) / size; - vertex(pose, buffer, face, textured, s0, t0); - vertex(pose, buffer, face, textured, s0, t1); - vertex(pose, buffer, face, textured, s1, t1); - vertex(pose, buffer, face, textured, s1, t0); + vertex(pose, buffer, face, s0, t0); + vertex(pose, buffer, face, s0, t1); + vertex(pose, buffer, face, s1, t1); + vertex(pose, buffer, face, s1, t0); } } @@ -300,13 +335,13 @@ private static void draw(PoseStack.Pose pose, VertexConsumer buffer, Face face) * The face's point {@code s} along its first and last corners' edge and {@code t} towards its * second and third's, as its corners wind: corner 0 at (0, 0), 1 at (0, 1), 2 at (1, 1), 3 at (1, 0). */ - private static void vertex(PoseStack.Pose pose, VertexConsumer buffer, Face face, boolean textured, float s, float t) { + private static void vertex(PoseStack.Pose pose, VertexConsumer buffer, Face face, float s, float t) { var c = face.corners(); - float x = grown(lerp(lerp(c[0].x(), c[3].x(), s), lerp(c[1].x(), c[2].x(), s), t)); - float y = grown(lerp(lerp(c[0].y(), c[3].y(), s), lerp(c[1].y(), c[2].y(), s), t)); - float z = grown(lerp(lerp(c[0].z(), c[3].z(), s), lerp(c[1].z(), c[2].z(), s), t)); + float x = oversized(lerp(lerp(c[0].x(), c[3].x(), s), lerp(c[1].x(), c[2].x(), s), t)); + float y = oversized(lerp(lerp(c[0].y(), c[3].y(), s), lerp(c[1].y(), c[2].y(), s), t)); + float z = oversized(lerp(lerp(c[0].z(), c[3].z(), s), lerp(c[1].z(), c[2].z(), s), t)); var vertex = buffer.addVertex(pose, x, y, z); - if (textured) { + if (face.textured()) { var uv = face.uvs(); vertex.setColor(face.colour()) .setUv(lerp(lerp(uv[0], uv[6], s), lerp(uv[2], uv[4], s), t), lerp(lerp(uv[1], uv[7], s), lerp(uv[3], uv[5], s), t)) @@ -314,15 +349,11 @@ private static void vertex(PoseStack.Pose pose, VertexConsumer buffer, Face face } } - private static float grown(float coordinate) { - return 0.5F + (coordinate - 0.5F) * GROWN; + private static float oversized(float coordinate) { + return 0.5F + (coordinate - 0.5F) * OVERSIZE; } private static float lerp(float from, float to, float by) { return from + (to - from) * by; } - - private static boolean solid(BlockState state) { - return !state.isAir() && state.getFluidState().isEmpty(); - } }