docs: add custom part lifecycle guide

This commit is contained in:
Algent committed 2026-09-14 12:01:54 +02:00
1 parent 7a9b05c6b1
commit bf4343d1fc
4 files changed
+209 -4

No files matched your search

+1 -2
View File
@@ -31,6 +31,7 @@ Existing reflective binaries remain supported until consumer release and pack ad
| Task | API and guide |
| --- | --- |
| Define, place, persist, synchronize and remove a custom part | `TMultiPart`, `canPlacePart`, `addPart`, `remPart` — [end-to-end custom-part lifecycle](api/CUSTOM_PARTS.md) |
| Register block converters | `registerConverter(IPartConverter)` — [registration, state ownership and committed lifecycle](api/BLOCK_CONVERTERS.md) |
| Register part factories during mod initialization | `registerPartFactory(IPartFactory2, String...)` — [factory timing, payload ownership and migration](api/PART_REGISTRATION.md) |
| Find the factory registered for a part type | `getPartFactory(String)` — [lookup ownership and Schematica reflection migration](api/FACTORY_LOOKUP.md) |
@@ -65,8 +66,6 @@ compile classpath for overload resolution, as the [loading guide](api/PART_LOADI
| Area | Starting points |
| --- | --- |
| Define a custom part | Extend [TMultiPart](../src/main/scala/codechicken/multipart/TMultiPart.java); implement the required capability interfaces. The [built-in parts](../src/main/scala/codechicken/multipart/minecraft) show Java implementations |
| Find, place and remove parts | [TileMultipart](../src/main/scala/codechicken/multipart/TileMultipart.java): `getTile`, `canPlacePart`, `addPart`, `remPart`. Retain the tile returned by changes because generated capabilities can replace the instance |
| Construct a server composite tile from parts, or restore saved multipart NBT | [MultipartHelper](../src/main/scala/codechicken/multipart/MultipartHelper.java): `createTileFromParts(Iterable)`, `createTileFromNBT(World, NBTTagCompound)` |
| Register microblock materials | [MicroMaterialRegistry](../src/main/scala/codechicken/microblock/MicroMaterialRegistry.java) and [BlockMicroMaterial](../src/main/scala/codechicken/microblock/BlockMicroMaterial.java) |
| Register generated tile traits or pass-through interfaces | [MultipartGenerator](../src/main/scala/codechicken/multipart/MultipartGenerator.java); see [custom tile-trait authoring](api/CUSTOM_TILE_TRAITS.md) and [safe capability access](api/TILE_TRAIT_ACCESS.md) |
+98
View File
@@ -0,0 +1,98 @@
# Writing and placing a custom part
[API index](../API.md) · [Factory registration](PART_REGISTRATION.md)
This guide connects the ordinary custom-part lifecycle from registration through placement, saved state, client
state and removal. The [compiling example](../../src/functionalTest/java/codechicken/multipart/examples/PartRegistrationExample.java)
uses one integer as representative state; replace its empty geometry and rendering with the capabilities your part
needs.
The APIs shown here are available on the `algent/java` branch and do not yet have a minimum published dependency
version. Existing part binaries remain supported; use the [release notes](../RELEASE_NOTES.md) when rebuilding source.
## Register and construct
Give every published part type a stable persistent ID and register its factory during common `preInit` or `init`, on
both client and server:
```java
MultiPartRegistry.registerPartFactory(new PartRegistrationExample(), "yourmod:part");
```
The factory returns a fresh, unbound part. It should select the implementation, not finish loading it: FMP calls
`load` after the NBT factory path and `readDesc` after the packet factory path. Preserve existing IDs and packet
prefixes when migrating. See [factory registration](PART_REGISTRATION.md) for payload ownership and failure ordering.
A basic part extends `TMultiPart`, returns its registered ID from `getType()`, and overrides only the capabilities and
callbacks it needs. The base class ticks by default, so a part without per-tick logic should return `false` from
`doesTick()` as the example does. Add collision/ray-trace boxes, drops, rendering, slots, redstone or other capability
interfaces according to the part's real behavior.
## Place on the server
Choose the target from the item or interaction, then check and place on the server:
```java
TileMultipart tile = PartRegistrationExample.place(world, pos, initialValue);
if (tile == null) {
return false;
}
```
The example returns null on the client or when `TileMultipart.canPlacePart` rejects the candidate. A successful
`TileMultipart.addPart` installs or updates the generated container, binds the part, calls `onAdded`, notifies the
world and clients, and returns the current tile. Retain that return value: adding a capability can replace a previous
tile instance. A successful `canPlacePart` is only a check; do not mutate inventory or other durable state until
`addPart` succeeds.
## Persist and synchronize state
Use separate callbacks for disk and network state:
| Purpose | Write | Read | When |
| --- | --- | --- | --- |
| Saved server state | `save(NBTTagCompound)` | `load(NBTTagCompound)` | World save and server reconstruction |
| Initial client state | `writeDesc(MCDataOutput)` | `readDesc(MCDataInput)` | Chunk description and part addition |
| Later client changes | `sendDescUpdate()` | Base `read` calls `readDesc` | Buffered part update |
The example's `setValue` is server-owned and performs the three actions needed because its value is assumed to affect
all three concerns:
```java
sendDescUpdate();
tile().notifyPartChange(this);
tile().markDirty();
```
`sendDescUpdate` synchronizes the description payload; the base client `read` then calls `readDesc` and marks the tile
for rendering. `notifyPartChange` calls sibling-part callbacks and updates the block, neighbors and lighting.
`markDirty` requests persistence. Use only the calls the changed state requires, but keep packet reads exactly
symmetric with writes. Run authoritative mutation on the server; the client applies network state in `readDesc` or a
custom `read` implementation.
## Lifecycle callbacks
`onAdded` / `onRemoved` describe placement and removal. Their base implementations call `onWorldJoin` /
`onWorldSeparate`. Server chunk load/unload and movement can call the world-level hooks again, while initial client
reconstruction calls `onWorldJoin` without `onAdded`. Put repeatable external-network registration in the world-level
hooks, and one-time placement/removal effects in the narrower hooks. Preserve the base call when overriding a narrow
hook unless deliberately replacing its world-level behavior.
`preRemove` runs before the part leaves storage and before its binding is cleared. Remove on the server and retain the
returned tile because dropping the last capability can replace the container, while removing the final part returns
null after the block is removed:
```java
TileMultipart current = part.tile();
TileMultipart replacement = current.remPart(part);
```
Ordinary placement and removal already perform container synchronization and notifications. The generation,
`setPartList` and `loadPartList` APIs are for controlled reconstruction and have different ownership rules; see
[part loading](PART_LOADING.md) and [composite generation](COMPOSITE_GENERATION.md) before using them.
## Validation boundary
`compileJavaApiExamples` compiles this example against the packaged dev jar with Java 8 and without Scala libraries.
The Forge functional suite covers registration, placement, binding, NBT/description state, updates and final removal.
Real geometry, interaction and rendering still need a physical-client check for the implementing mod.
@@ -1,13 +1,17 @@
package codechicken.multipart.examples;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.world.World;
import codechicken.lib.data.MCDataInput;
import codechicken.lib.data.MCDataOutput;
import codechicken.lib.vec.BlockCoord;
import codechicken.multipart.MultiPartRegistry;
import codechicken.multipart.MultiPartRegistry.IPartFactory2;
import codechicken.multipart.TMultiPart;
import codechicken.multipart.TileMultipart;
/** Compiling example for docs/api/PART_REGISTRATION.md; geometry and gameplay callbacks belong to the real part. */
/** Compiling example for the part-registration and custom-part lifecycle guides. */
public final class PartRegistrationExample implements IPartFactory2 {
public static final String PART_TYPE = "yourmod:part";
@@ -27,11 +31,70 @@ public final class PartRegistrationExample implements IPartFactory2 {
return new ExamplePart();
}
private static final class ExamplePart extends TMultiPart {
/** Call from the server-side item/action after choosing the target position. */
public static TileMultipart place(World world, BlockCoord pos, int value) {
ExamplePart part = new ExamplePart(value);
if (world.isRemote || !TileMultipart.canPlacePart(world, pos, part)) {
return null;
}
return TileMultipart.addPart(world, pos, part);
}
public static final class ExamplePart extends TMultiPart {
private int value;
public ExamplePart() {}
public ExamplePart(int value) {
this.value = value;
}
@Override
public String getType() {
return PART_TYPE;
}
@Override
public boolean doesTick() {
return false;
}
public int value() {
return value;
}
/** Example for state that affects persistence, clients, sibling parts and rendering. */
public void setValue(int value) {
if (this.value == value) {
return;
}
this.value = value;
if (world() != null && !world().isRemote) {
sendDescUpdate();
tile().notifyPartChange(this);
tile().markDirty();
}
}
@Override
public void save(NBTTagCompound tag) {
tag.setInteger("value", value);
}
@Override
public void load(NBTTagCompound tag) {
value = tag.getInteger("value");
}
@Override
public void writeDesc(MCDataOutput packet) {
packet.writeInt(value);
}
@Override
public void readDesc(MCDataInput packet) {
value = packet.readInt();
}
}
}
@@ -2,6 +2,7 @@ package codechicken.multipart.test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
@@ -14,17 +15,22 @@ import java.util.NoSuchElementException;
import java.util.function.BiConsumer;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.World;
import org.junit.jupiter.api.Test;
import codechicken.lib.data.MCDataInput;
import codechicken.lib.packet.PacketCustom;
import codechicken.lib.vec.BlockCoord;
import codechicken.multipart.MultiPartRegistry;
import codechicken.multipart.MultiPartRegistry$;
import codechicken.multipart.MultiPartRegistry.IPartFactory;
import codechicken.multipart.MultiPartRegistry.IPartFactory2;
import codechicken.multipart.TMultiPart;
import codechicken.multipart.TileMultipart;
import codechicken.multipart.examples.PartRegistrationExample;
import codechicken.multipart.examples.PartRegistrationExample.ExamplePart;
import cpw.mods.fml.common.Loader;
import cpw.mods.fml.common.ModContainer;
import scala.collection.JavaConversions;
@@ -33,6 +39,8 @@ import scala.runtime.AbstractFunction2;
class PartRegistrationFunctionalTest {
private static final BlockCoord EXAMPLE_POS = new BlockCoord(42, 200, 32);
private static final List<BiConsumer<IPartFactory2, String[]>> ENTRIES = Arrays.asList(
(factory, types) -> MultiPartRegistry.registerParts(factory, types),
(factory, types) -> MultiPartRegistry$.MODULE$.registerParts(factory, types),
@@ -109,6 +117,43 @@ class PartRegistrationFunctionalTest {
assertEquals(17, packet.readUByte(), "This example has no constructor discriminator");
}
@Test
void javaExamplePlacesPersistsSynchronizesAndRemovesState() {
World world = MinecraftServer.getServer().worldServers[0];
world.getChunkFromBlockCoords(EXAMPLE_POS.x, EXAMPLE_POS.z);
world.setBlockToAir(EXAMPLE_POS.x, EXAMPLE_POS.y, EXAMPLE_POS.z);
try {
TileMultipart tile = PartRegistrationExample.place(world, EXAMPLE_POS, 7);
assertNotNull(tile);
ExamplePart part = (ExamplePart) tile.jPartList().get(0);
assertSame(tile, part.tile());
assertEquals(7, part.value());
assertFalse(part.doesTick());
part.setValue(23);
assertEquals(23, part.value());
NBTTagCompound saved = new NBTTagCompound();
tile.writeToNBT(saved);
TileMultipart loaded = TileMultipart.createFromNBT(saved);
assertNotNull(loaded);
assertEquals(23, ((ExamplePart) loaded.jPartList().get(0)).value());
PacketCustom description = new PacketCustom("test", 1);
part.writeDesc(description);
ExamplePart client = new ExamplePart();
client.readDesc(new PacketCustom(description.getByteBuf().copy()));
assertEquals(23, client.value());
TileMultipart current = part.tile();
assertNull(current.remPart(part), "Removing the last part leaves no replacement tile");
assertNull(part.tile());
assertTrue(world.isAirBlock(EXAMPLE_POS.x, EXAMPLE_POS.y, EXAMPLE_POS.z));
} finally {
world.setBlockToAir(EXAMPLE_POS.x, EXAMPLE_POS.y, EXAMPLE_POS.z);
}
}
@Test
void registrationIsLazyOwnedByTheActiveModAndDoesNotRetainTheArray() {
assertEquals("ForgeMultipartTests", owner.getModId());