Skip to content
Open
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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group = "org.inventivetalent.tessera"
version = "26.6.1"
version = "26.7.0-SNAPSHOT"

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
Expand Down
63 changes: 63 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,69 @@ public void onBaked(TesseraBlockBakedEvent event) {
}
```

## Replacing the break animation (API v2)

A plugin can take over the animation Tessera plays when a block breaks by
registering a **break-effect provider**. Tessera still does everything up to
that moment — gating, variant rotation, lattice spawning, the progress-driven
mining wave, per-viewer transport — and then offers the surviving fragments to
your provider instead of running its built-in collapse:

```java
TesseraApi tessera = Tessera.api();
if (tessera == null || tessera.apiVersion() < 2) {
getLogger().severe("Tessera with API v2 required");
return;
}
tessera.registerBreakEffectProvider(this, new BreakEffectProvider() {
@Override
public boolean onBlockShatter(ShatterHandle handle, ShatterContext ctx) {
for (ShatterChunk chunk : handle.chunks()) {
// animate: chunk.setPose(centerOffset, tumble, scale, delay, interp)
}
// you now own the fragments — despawn when the effect is done:
Bukkit.getScheduler().runTaskLater(plugin, handle::despawn, 40L);
return true; // claimed; false = let the built-in effect run
}

@Override
public boolean wantsInteriorFill() {
return true; // solid lattice even if animation.fillInterior is off
}
});
```

The contract, briefly:

- **One slot, first-wins.** A second plugin's registration fails with a log
line; two providers fighting over the same break can't be reconciled. The
provider is detached automatically when its plugin disables.
- **Claim = own the despawn.** Return `true` and you must eventually call
`handle.despawn()` — that removes the fragments and frees Tessera's
concurrency slot. As a backstop, Tessera force-despawns a claimed break
after `limits.providerMaxEffectDurationMs` (config, default 10s);
`despawn()` is idempotent so a well-behaved provider never notices.
- **Decline = built-in effect.** Return `false` (or throw — logged once) and
Tessera animates the break exactly as if no provider were registered.
- **Progress mode hands over the remainder.** With the default
`animation.mode: progress`, the mining wave consumes fragments while the
player digs; on the real break your provider receives only what survived,
positioned and scaled mid-wave. `ShatterContext.mode()` tells you which
path you're on.
- **Pose, don't recompute.** `ShatterChunk.setPose(centerOffset, tumble,
scale, delayTicks, durationTicks)` places a fragment's cube center relative
to `handle.origin()` with a world-space rotation on top of the blockstate
orientation. The player-head model's render-offset compensation is
re-solved internally — with the raw `setTransformation` escape hatch,
rotating the left rotation makes fragments orbit off-center. A positive
`durationTicks` rides the client's display interpolation, so a handful of
poses per second animates smoothly with no per-tick server work.
- **Main thread only**, like the rest of the break pipeline. Fragments are
visible only to the breaker (`ShatterContext.breaker()`).

In-game check: `/tessera test stone` routes through the registered provider;
`/tessera test stone builtin` forces the built-in effect for comparison.

## Versioning

`TesseraApi.VERSION` (and `apiVersion()`) is bumped on incompatible changes. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,33 @@ public Vector3f translationFor(ChunkCoord coord, Quaternionf faceRot, float over
public Quaternionf blockRotation() {
return new Quaternionf(blockRotation);
}

/**
* Translation that puts a chunk cube's geometric center at
* {@code worldCenterOffset} (relative to the display's entity location)
* for an <em>arbitrary</em> left rotation — unlike
* {@link #translationFor(ChunkCoord, Quaternionf, float)}, which assumes
* the left rotation is this geometry's block rotation and derives the
* center from the grid cell.
*
* <p>{@code worldVertex = entityLocation + T + L*S*R*v}, so the rendered
* cube center sits at {@code T + L*S*R*CUBE_CENTER_PRE}. Solving for the
* requested center: {@code T = worldCenterOffset - L*(R*CUBE_CENTER_PRE)*s}.
* Callers animating a chunk (moving or spinning it after spawn) must
* recompute {@code T} with this whenever {@code L} or the scale changes,
* or the cube orbits its own center instead of rotating in place.
*
* <p>Pure function of its arguments plus the tunable
* {@link #cubeCenterPre()}; safe from any thread that isn't concurrently
* re-tuning the center via {@code /tessera debug center}.
*/
public static Vector3f poseTranslation(Vector3f worldCenterOffset,
Quaternionf leftRotation,
Quaternionf faceRot,
float scale) {
Vector3f disp = new Quaternionf(faceRot).transform(new Vector3f(CUBE_CENTER_PRE));
disp.mul(scale);
new Quaternionf(leftRotation).transform(disp);
return new Vector3f(worldCenterOffset).sub(disp);
}
}
32 changes: 31 additions & 1 deletion src/main/java/org/inventivetalent/tessera/core/FakeBlock.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public final class FakeBlock {
private final List<ChunkRef> chunks;
private final Quaternionf blockRotation;
private final TransportSession session;
private final List<Runnable> despawnCallbacks = new ArrayList<>(2);
private boolean despawned = false;

public FakeBlock(Location origin, BlockKey blockKey, int gridN, List<ChunkRef> chunks,
Expand Down Expand Up @@ -53,10 +54,39 @@ public FakeBlock(Location origin, BlockKey blockKey, int gridN, List<ChunkRef> c
*/
public Quaternionf blockRotation() { return new Quaternionf(blockRotation); }

/**
* Runs {@code callback} when {@link #despawn()} executes — the hook for
* anything whose lifetime is tied to this FakeBlock (concurrency-slot
* release, bookkeeping). Runs immediately if already despawned. Callbacks
* fire exactly once, after the session closes, each guarded so one
* failure can't starve the rest. Main thread only.
*/
public void onDespawn(Runnable callback) {
if (despawned) {
callback.run();
return;
}
despawnCallbacks.add(callback);
}

/** Removes every spawned display handle. Idempotent. Must be called on the main thread. */
public void despawn() {
if (despawned) return;
despawned = true;
session.close();
try {
session.close();
} finally {
// Callbacks run even when the transport teardown throws —
// whatever is keyed to this FakeBlock's lifetime (concurrency
// slot, registries) must not leak on a failed close.
for (Runnable r : despawnCallbacks) {
try {
r.run();
} catch (RuntimeException ignored) {
// A misbehaving callback must not block the others.
}
}
despawnCallbacks.clear();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.inventivetalent.tessera.core.BakeKey;
import org.inventivetalent.tessera.core.BlockKey;
import org.inventivetalent.tessera.core.FakeBlock;
import org.inventivetalent.tessera.api.effect.ShatterContext;
import org.inventivetalent.tessera.core.VariantKey;
import org.inventivetalent.tessera.effect.EffectContext;
import org.inventivetalent.tessera.effect.builtin.DirectionalShrinkEffect;
Expand Down Expand Up @@ -129,19 +130,27 @@ private void spawn(Player viewer, BakeKey bakeKey, BlockData blockData,
String matchedKey = VariantKey.pickMatching(fullStateKey, registry.variantsFor(key).keySet());
Quaternionf blockRotation = registry.rotationFor(key, matchedKey);

boolean fillInterior = cfg.fillInterior()
|| plugin.breakEffectProviders().wantsInteriorFill();
FakeBlock fb;
try {
fb = factory.create(viewer, breakLoc, bakeKey, blockRotation, cfg.fillInterior(), eyeDir);
fb = factory.create(viewer, breakLoc, bakeKey, blockRotation, fillInterior, eyeDir);
} catch (RuntimeException re) {
active.decrementAndGet();
plugin.getLogger().warning("Failed to spawn FakeBlock for " + bakeKey + ": " + re.getMessage());
return;
}
// The concurrency slot is held for exactly the FakeBlock's lifetime:
// whoever despawns it (the effect's scheduled cleanup, a break-effect
// provider, plugin shutdown, ...) releases the slot, so effects of any
// duration can't wedge the cap or release it early.
fb.onDespawn(active::decrementAndGet);

ShatterContext shatterContext = new ShatterContext(
viewer, eyeDir, blockData, ShatterContext.Mode.POST_BREAK);
if (plugin.breakEffectProviders().dispatch(fb, shatterContext)) return;

EffectContext ctx = new EffectContext(eyeDir, System.currentTimeMillis(), cfg.effectDurationMs(), plugin);
new DirectionalShrinkEffect(cfg.collapseStyle()).applyTimed(fb, ctx);

long despawnTicks = (cfg.effectDurationMs() / 50L) + 5L;
plugin.getServer().getScheduler().runTaskLater(plugin, active::decrementAndGet, despawnTicks);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.inventivetalent.tessera.plugin;

import org.inventivetalent.tessera.api.effect.ShatterContext;
import org.inventivetalent.tessera.assemble.BlockGeometry;
import org.inventivetalent.tessera.assemble.FakeBlockFactory;
import org.inventivetalent.tessera.core.*;
Expand Down Expand Up @@ -267,7 +268,7 @@ public void onInteract(PlayerInteractEvent event) {
FakeBlockFactory.PreloadPlan plan;
try {
plan = factory.completePlan(breakLoc, preload.bakeKey(),
preload.blockRotation(), cfg.fillInterior(), eyeDir,
preload.blockRotation(), effectiveFillInterior(cfg), eyeDir,
preload.prespawnedChunks(), preload.session());
} catch (RuntimeException re) {
preload.session().close();
Expand All @@ -279,6 +280,7 @@ public void onInteract(PlayerInteractEvent event) {
Math.floor(breakLoc.getX()), Math.floor(breakLoc.getY()), Math.floor(breakLoc.getZ()));
FakeBlock fb = new FakeBlock(origin, preload.bakeKey().block(), gridN,
new ArrayList<>(plan.frontRefs().values()), preload.blockRotation(), plan.session());
fb.onDespawn(active::decrementAndGet);
TrackedBreak tb = buildTrackedBreak(player.getUniqueId(),
preload.bakeKey().block(), fb, block.getBlockData(), eyeDir, plan);
tracker.put(posKey, tb);
Expand Down Expand Up @@ -366,6 +368,16 @@ private static boolean tooFast(Block block, Player player, TesseraConfig cfg) {
return min > 0 && estimateBreakDurationMs(block, player) < min;
}

/**
* Interior fill applies when the server config asks for it <em>or</em> a
* registered break-effect provider requests it (a provider that animates
* the whole block wants a solid lattice even on servers that keep the
* built-in effect hollow).
*/
private boolean effectiveFillInterior(TesseraConfig cfg) {
return cfg.fillInterior() || plugin.breakEffectProviders().wantsInteriorFill();
}

@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
UUID id = event.getPlayer().getUniqueId();
Expand Down Expand Up @@ -413,7 +425,7 @@ private void spawnAndRegister(Player player, Block block, Location breakLoc,
FakeBlockFactory.PreloadPlan plan;
try {
plan = factory.preloadAndPending(player, breakLoc, bakeKey, blockRotation,
cfg.fillInterior(), eyeDir);
effectiveFillInterior(cfg), eyeDir);
} catch (RuntimeException re) {
active.decrementAndGet();
plugin.getLogger().warning("Failed to spawn FakeBlock for " + bakeKey + ": " + re.getMessage());
Expand All @@ -425,6 +437,7 @@ private void spawnAndRegister(Player player, Block block, Location breakLoc,
Math.floor(breakLoc.getX()), Math.floor(breakLoc.getY()), Math.floor(breakLoc.getZ()));
FakeBlock fb = new FakeBlock(origin, key, gridN,
new ArrayList<>(plan.frontRefs().values()), blockRotation, plan.session());
fb.onDespawn(active::decrementAndGet);

TrackedBreak tb = buildTrackedBreak(player.getUniqueId(), key, fb, blockData, eyeDir, plan);
tracker.put(posKey, tb);
Expand Down Expand Up @@ -629,6 +642,8 @@ void onRealBreak(BlockPosKey posKey) {
TrackedBreak tb = tracker.remove(posKey);
if (tb == null) return;
cancelReverseTask(tb);
cancelSmoothTask(tb);
cancelPendingSpawnTask(tb);
if (tb.barrierSent) {
Player p = Bukkit.getPlayer(tb.currentPlayerId);
if (p != null) {
Expand All @@ -641,12 +656,92 @@ void onRealBreak(BlockPosKey posKey) {
}
tb.barrierSent = false;
}
disposeImmediate(tb, /*restoreBlock=*/ false);
clearPreloadsAt(posKey);
if (handOffToProvider(tb)) {
if (plugin.tesseraConfig().debug()) plugin.getLogger().info(
"[" + ts() + "] [debug-progress] real-break-handoff " + tb.key + " at " + posKey
+ " chunks=" + tb.fakeBlock.chunks().size());
return;
}
disposeImmediate(tb, /*restoreBlock=*/ false);
if (plugin.tesseraConfig().debug()) plugin.getLogger().info(
"[" + ts() + "] [debug-progress] real-break " + tb.key + " at " + posKey);
}

/**
* Offer the surviving lattice to a registered break-effect provider
* instead of disposing it. The wave consumed the near-side chunks; what
* the provider receives is the material that was still un-mined at the
* moment the block actually broke:
* <ol>
* <li>all still-pending chunks (back faces + interior) are spawned in
* one batch, at the shell factor matching the current lattice;</li>
* <li>the spawn-time shell compression is undone if it hadn't been —
* the real block is gone at this instant, so there is nothing to
* z-fight with. Rescaling reads the actual per-chunk transform
* scales (not the wave-tracking arrays, whose freshly-spawned
* interior slots hold a sentinel);</li>
* <li>chunks the wave shrank to invisibility but hadn't culled yet are
* despawned, so mined-away material stays gone.</li>
* </ol>
* Returns false (leaving teardown to the caller) when no provider is
* registered or the provider declines.
*/
private boolean handOffToProvider(TrackedBreak tb) {
BreakEffectProviders providers = plugin.breakEffectProviders();
if (!providers.hasProvider() || tb.fakeBlock.despawned()) return false;

materializeRemainder(tb);

if (!tb.shellExpanded) {
float[] actual = DirectionalShrinkEffect.captureBaseScales(tb.fakeBlock);
DirectionalShrinkEffect.rescaleShell(tb.fakeBlock, actual, actual.clone(),
1f / FakeBlockFactory.INITIAL_SHELL_COMPRESSION, 0, null);
tb.shellExpanded = true;
}

float minScale = (float) plugin.tesseraConfig().progressMinDelta();
for (ChunkRef ref : tb.fakeBlock.chunks()) {
if (ref.handle().isAlive()
&& ref.handle().getTransformation().getScale().x() < minScale) {
ref.handle().despawn();
}
}

ShatterContext context = new ShatterContext(
Bukkit.getPlayer(tb.currentPlayerId), tb.eyeDir,
tb.originalBlockData, ShatterContext.Mode.PROGRESS);
return providers.dispatch(tb.fakeBlock, context);
}

/**
* Spawn every remaining pending chunk of {@code tb} in one batch,
* appending to the FakeBlock's chunk list. Sized for the worst case of
* gridN³ chunks; at the default gridN=4 that's at most 64. The
* wave-tracking arrays are deliberately not updated — after a handoff the
* progress machinery never touches this tracker again.
*/
private void materializeRemainder(TrackedBreak tb) {
List<FakeBlockFactory.PendingChunkSpec> pending = tb.pendingChunks;
if (pending == null || pending.isEmpty()) return;
tb.pendingChunks = new ArrayList<>();

float shellFactor = tb.shellExpanded ? 1.0f : FakeBlockFactory.INITIAL_SHELL_COMPRESSION;
FakeBlockFactory.PendingChunkSpec donor = null;
for (FakeBlockFactory.PendingChunkSpec s : pending) {
if (s.interior()) { donor = s; break; }
}
FakeBlockFactory.PendingSpawnContext ctx = factory.beginPendingBatch(tb.fakeBlock, donor);
for (FakeBlockFactory.PendingChunkSpec spec : pending) {
try {
tb.fakeBlock.chunks().add(factory.spawnPendingChunk(ctx, spec, shellFactor));
} catch (RuntimeException re) {
plugin.getLogger().warning(
"handoff spawn failed for " + spec.coord() + ": " + re.getMessage());
}
}
}

public boolean isTracked(BlockPosKey posKey) {
return tracker.get(posKey) != null;
}
Expand Down Expand Up @@ -700,7 +795,10 @@ private void disposeImmediate(TrackedBreak tb, boolean restoreBlock) {
} catch (RuntimeException re) {
plugin.getLogger().warning("despawn FakeBlock failed: " + re.getMessage());
}
active.decrementAndGet();
// The concurrency slot is released by the FakeBlock's onDespawn
// callback (registered at spawn), not here — despawn() is idempotent
// and the callback fires exactly once regardless of which teardown
// path got there first.
}

private static void cancelReverseTask(TrackedBreak tb) {
Expand Down
Loading
Loading