Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/fluid-bottles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Fluid bottles

Vanilla glass bottles only pick up water. MMDLib lets them hold any fluid in the
`#mmdlib:bottleable` fluid tag, without a mixin. A filled bottle is a single item,
`mmdlib:fluid_bottle`, that holds **333 mB**, a third of a bucket, of one fluid.

## Making a fluid bottleable

Add its **source** fluid to the tag with a datapack or your mod's data:

```json
// data/mmdlib/tags/fluid/bottleable.json
{ "replace": false, "values": ["example:molten_tin"] }
```

MMDLib ships the tag empty, so packs and mods decide what can be bottled.
Vanilla's own fluids (water, lava) are always left to vanilla, even if tagged:
a water bottle is still vanilla's water potion.

## What works

* **Right-click a source block** with a glass bottle to fill it. The source block
stays, as it does for water. Protected blocks (`Level.mayInteract`) are skipped.
* **Tanks and pipes** fill and empty bottles through the fluid item capability
(`Capabilities.FluidHandler.ITEM`), for example with `FluidUtil`. Filling takes
exactly 333 mB. Emptying a bottle leaves a glass bottle.
* **Name**: "Bottle of" plus the fluid's name, such as "Bottle of Molten Copper".
* **Look**: vanilla's potion bottle, with the fluid's still texture showing
through the potion's liquid area. It is tinted with whatever color the fluid
type registers on the client (`IClientFluidTypeExtensions#getTintColor`);
fluids without a tint show their texture as drawn. Glowing fluids glow.

## Other mods' glass bottle capabilities

If another mod already gives glass bottles a fluid capability, MMDLib doesn't
register its own: it logs that at startup and leaves filling glass bottles from
tanks to that mod. Right-clicking bottleable fluids, and filled MMDLib bottles,
work either way. MMDLib registers at the lowest priority, so mods registering at
the usual priority are seen first.

## Code

`zone.moddev.mc.lib.common.fluid.FluidBottles` holds the tag (`BOTTLEABLE`), the
capacity, the item (`BOTTLE`) and its fluid component (`CONTENT`).
`FluidBottleItem.filled(fluid)` makes a full bottle, and
`FluidBottleItem.contents(stack)` reads one. `FluidBottles.bottleable(fluid)` is
the single rule for what can be bottled.

`FluidBottleSmoke` (game tests) checks filling from the world, water left to
vanilla, untagged fluids refused, and filling and emptying through tanks.
1 change: 1 addition & 0 deletions src/main/java/zone/moddev/mc/lib/MMDLib.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ public MMDLib(IEventBus modBus, ModContainer container) {
NeoForge.EVENT_BUS.addListener(
zone.moddev.mc.lib.machine.neoforge.MachinePartsLoader::register);
modBus.addListener(zone.moddev.mc.lib.util.Registries::register);
zone.moddev.mc.lib.common.fluid.FluidBottles.register(modBus);

if (ModList.get().isLoaded("crafttweaker")) CraftTweakerInit.register(modBus);

Expand Down
24 changes: 24 additions & 0 deletions src/main/java/zone/moddev/mc/lib/client/FluidBottleClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package zone.moddev.mc.lib.client;

import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterColorHandlersEvent;
import net.neoforged.neoforge.client.model.DynamicFluidContainerModel;

import zone.moddev.mc.lib.common.fluid.FluidBottles;

/**
* Colors the fluid in a {@code mmdlib:fluid_bottle} with whatever tint its fluid type registers on
* the client ({@code IClientFluidTypeExtensions#getTintColor}); fluids that register none show
* their texture as drawn.
*/
@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public final class FluidBottleClient {
private FluidBottleClient() {}

@SubscribeEvent
public static void colors(RegisterColorHandlersEvent.Item event) {
event.register(new DynamicFluidContainerModel.Colors(), FluidBottles.BOTTLE.get());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package zone.moddev.mc.lib.common.fluid;

import net.minecraft.world.item.ItemStack;

import net.neoforged.neoforge.fluids.FluidStack;
import net.neoforged.neoforge.fluids.capability.IFluidHandlerItem;

/**
* The fluid capability of an empty glass bottle: it takes exactly {@link FluidBottles#CAPACITY} of
* a bottleable fluid at once and becomes a {@link FluidBottleItem}, the way NeoForge's bucket
* wrapper turns an empty bucket into a filled one. Anything else is refused, so other fluid
* handling of glass bottles is left alone.
*/
final class EmptyBottleFluidHandler implements IFluidHandlerItem {
private ItemStack container;

EmptyBottleFluidHandler(ItemStack container) {
this.container = container;
}

@Override
public ItemStack getContainer() {
return container;
}

@Override
public int getTanks() {
return 1;
}

@Override
public FluidStack getFluidInTank(int tank) {
return FluidStack.EMPTY;
}

@Override
public int getTankCapacity(int tank) {
return FluidBottles.CAPACITY;
}

@Override
public boolean isFluidValid(int tank, FluidStack stack) {
return FluidBottles.bottleable(stack.getFluid());
}

@Override
public int fill(FluidStack resource, FluidAction action) {
// A single bottle, filled whole: a stack of bottles is filled one at a time by FluidUtil.
if (container.getCount() != 1
|| resource.getAmount() < FluidBottles.CAPACITY
|| !FluidBottles.bottleable(resource.getFluid())) return 0;
if (action.execute()) container = FluidBottleItem.filled(resource.getFluid());
return FluidBottles.CAPACITY;
}

@Override
public FluidStack drain(FluidStack resource, FluidAction action) {
return FluidStack.EMPTY;
}

@Override
public FluidStack drain(int maxDrain, FluidAction action) {
return FluidStack.EMPTY;
}
}
44 changes: 44 additions & 0 deletions src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottleItem.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package zone.moddev.mc.lib.common.fluid;

import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.material.Fluid;

import net.neoforged.neoforge.fluids.FluidStack;
import net.neoforged.neoforge.fluids.SimpleFluidContent;

/**
* {@code mmdlib:fluid_bottle}: a glass bottle holding {@link FluidBottles#CAPACITY} of one fluid,
* kept in the {@link FluidBottles#CONTENT} component. Named after its fluid ("Bottle of Molten
* Copper"); emptying it, through its fluid capability, leaves a glass bottle.
*/
public final class FluidBottleItem extends Item {
/** One-high stacks, like potions: bottles of different fluids cannot share a stack anyway. */
public FluidBottleItem(Properties properties) {
super(properties.stacksTo(1).craftRemainder(Items.GLASS_BOTTLE));
}

/** The fluid in {@code stack}; empty for an unfilled bottle. */
public static FluidStack contents(ItemStack stack) {
return stack.getOrDefault(FluidBottles.CONTENT.get(), SimpleFluidContent.EMPTY).copy();
}

/** A bottle full of {@code fluid}. */
public static ItemStack filled(Fluid fluid) {
var stack = new ItemStack(FluidBottles.BOTTLE.get());
stack.set(
FluidBottles.CONTENT.get(),
SimpleFluidContent.copyOf(new FluidStack(fluid, FluidBottles.CAPACITY)));
return stack;
}

@Override
public Component getName(ItemStack stack) {
var fluid = contents(stack);
return fluid.isEmpty()
? super.getName(stack)
: Component.translatable(getDescriptionId(stack) + ".filled", fluid.getHoverName());
}
}
147 changes: 147 additions & 0 deletions src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottles.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package zone.moddev.mc.lib.common.fluid;

import java.util.function.Supplier;

import org.slf4j.Logger;

import com.mojang.logging.LogUtils;

import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.stats.Stats;
import net.minecraft.tags.TagKey;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.ItemUtils;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.material.Fluid;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;

import net.neoforged.bus.api.EventPriority;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.capabilities.Capabilities;
import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
import net.neoforged.neoforge.fluids.SimpleFluidContent;
import net.neoforged.neoforge.fluids.capability.templates.FluidHandlerItemStack;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;

import zone.moddev.mc.lib.MMDLib;

/**
* Lets vanilla glass bottles hold any fluid in {@link #BOTTLEABLE}, without a mixin:
*
* <ul>
* <li>Right-clicking a source block of such a fluid with a glass bottle gives a {@link
* FluidBottleItem} and leaves the source in place, as water bottles do.
* <li>Glass bottles and filled bottles get a fluid capability, so tanks and pipes fill and empty
* them. If another mod already gives glass bottles one, MMDLib logs that and leaves glass
* bottles to it.
* </ul>
*
* Vanilla's own fluids (water, lava) are always left to vanilla, tagged or not.
*/
public final class FluidBottles {
private static final Logger LOGGER = LogUtils.getLogger();

/** Source fluids glass bottles can hold. MMDLib ships it empty: packs and mods fill it. */
public static final TagKey<Fluid> BOTTLEABLE =
TagKey.create(Registries.FLUID, ResourceLocation.fromNamespaceAndPath(MMDLib.MODID, "bottleable"));

/** Millibuckets in a bottle: a third of a bucket, the common convention. */
public static final int CAPACITY = 333;

private static final DeferredRegister.DataComponents COMPONENTS =
DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MMDLib.MODID);

/** The fluid a {@link FluidBottleItem} holds. */
public static final Supplier<DataComponentType<SimpleFluidContent>> CONTENT =
COMPONENTS.registerComponentType(
"bottle_fluid",
builder ->
builder
.persistent(SimpleFluidContent.CODEC)
.networkSynchronized(SimpleFluidContent.STREAM_CODEC));

/** {@code mmdlib:fluid_bottle}. */
public static final DeferredHolder<net.minecraft.world.item.Item, FluidBottleItem> BOTTLE =
MMDLib.ITEMS.register(
"fluid_bottle", () -> new FluidBottleItem(new net.minecraft.world.item.Item.Properties()));

private FluidBottles() {}

/** Registers the component, the capabilities and the bottle-filling hook. */
public static void register(IEventBus modBus) {
COMPONENTS.register(modBus);
// Lowest priority, so mods registering at the usual priority are seen first.
modBus.addListener(EventPriority.LOWEST, FluidBottles::capabilities);
NeoForge.EVENT_BUS.addListener(FluidBottles::fillFromWorld);
}

/** Whether glass bottles can hold {@code fluid}: tagged, and not one of vanilla's. */
public static boolean bottleable(Fluid fluid) {
return !BuiltInRegistries.FLUID.getKey(fluid).getNamespace().equals("minecraft")
&& fluid.defaultFluidState().is(BOTTLEABLE);
}

private static void capabilities(RegisterCapabilitiesEvent event) {
event.registerItem(
Capabilities.FluidHandler.ITEM,
(stack, context) ->
new FluidHandlerItemStack.SwapEmpty(
CONTENT, stack, new net.minecraft.world.item.ItemStack(Items.GLASS_BOTTLE), CAPACITY) {
@Override
public boolean canFillFluidType(net.neoforged.neoforge.fluids.FluidStack fluid) {
return bottleable(fluid.getFluid());
}
},
BOTTLE.get());
if (event.isItemRegistered(Capabilities.FluidHandler.ITEM, Items.GLASS_BOTTLE)) {
LOGGER.info(
"Another mod already gives glass bottles a fluid capability; MMDLib leaves filling them"
+ " from tanks to it. Right-clicking bottleable fluids still works.");
return;
}
event.registerItem(
Capabilities.FluidHandler.ITEM,
(stack, context) -> new EmptyBottleFluidHandler(stack),
Items.GLASS_BOTTLE);
}

/**
* A glass bottle used on a bottleable source fills, both sides, before vanilla's bottle handling
* (which only knows water). Anything else goes on to vanilla.
*/
private static void fillFromWorld(PlayerInteractEvent.RightClickItem event) {
var held = event.getItemStack();
if (!held.is(Items.GLASS_BOTTLE)) return;
var player = event.getEntity();
var level = event.getLevel();
var hit = player.pick(player.blockInteractionRange(), 0, true);
if (hit.getType() != HitResult.Type.BLOCK || !(hit instanceof BlockHitResult block)) return;
var pos = block.getBlockPos();
var fluid = level.getFluidState(pos);
if (!fluid.isSource() || !bottleable(fluid.getType()) || !level.mayInteract(player, pos)) return;
level.playSound(
player,
player.getX(),
player.getY(),
player.getZ(),
SoundEvents.BOTTLE_FILL,
SoundSource.NEUTRAL,
1.0F,
1.0F);
player.awardStat(Stats.ITEM_USED.get(Items.GLASS_BOTTLE));
player.setItemInHand(
event.getHand(),
ItemUtils.createFilledResult(held, player, FluidBottleItem.filled(fluid.getType())));
event.setCanceled(true);
event.setCancellationResult(InteractionResult.sidedSuccess(level.isClientSide()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Glass bottles that hold any fluid in the {@code #mmdlib:bottleable} tag; see {@link
* zone.moddev.mc.lib.common.fluid.FluidBottles}.
*/
package zone.moddev.mc.lib.common.fluid;
4 changes: 3 additions & 1 deletion src/main/resources/assets/mmdlib/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -377,5 +377,7 @@
"screen.mmdlib.stats.cycles": "Cycles",
"screen.mmdlib.stats.cycle_tip": "Click to cycle tick / second / minute. Right-click to cycle backwards.",
"screen.mmdlib.stats.empty": "No statistics available.",
"screen.mmdlib.stats.tip": "Measured RF: latest 20 ticks. Cycles: up to 60 game seconds. Max cycles uses the active recipe duration; max use is peak recipe demand. Max transfer rates use one transfer per tick."
"screen.mmdlib.stats.tip": "Measured RF: latest 20 ticks. Cycles: up to 60 game seconds. Max cycles uses the active recipe duration; max use is peak recipe demand. Max transfer rates use one transfer per tick.",
"item.mmdlib.fluid_bottle": "Fluid Bottle",
"item.mmdlib.fluid_bottle.filled": "Bottle of %s"
}
11 changes: 11 additions & 0 deletions src/main/resources/assets/mmdlib/models/item/fluid_bottle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"parent": "neoforge:item/default",
"loader": "neoforge:fluid_container",
"fluid": "minecraft:empty",
"cover_is_mask": false,
"textures": {
"fluid": "minecraft:item/potion_overlay",
"cover": "minecraft:item/potion",
"particle": "minecraft:item/potion"
}
}
Loading
Loading