feat(felling): animate safe unlocked tree breaks

This commit is contained in:
dmg
2026-08-11 17:26:53 -04:00
parent 9600520e14
commit 87452e4b87
13 changed files with 671 additions and 17 deletions
@@ -0,0 +1,207 @@
package games.dmg.treefeller;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.WorldUnloadEvent;
/** Runs bounded detected trunk blocks through Spigot's player break path over time. */
public final class AnimatedTreeFellingEngine implements TreeFellingStarter, Listener, AutoCloseable {
private static final BlockPoint ORIGIN = new BlockPoint(0, 0, 0);
private final DelayedTaskScheduler scheduler;
private final AutomaticBreakRegistry automaticBreaks;
private final Predicate<Player> runtimeEligibility;
private final FellingObserver observer;
private final Consumer<Exception> failureHandler;
private final Map<UUID, Session> activePlayers = new HashMap<>();
private final Set<WorldBlockKey> claimedBlocks = new HashSet<>();
public AnimatedTreeFellingEngine(
DelayedTaskScheduler scheduler,
AutomaticBreakRegistry automaticBreaks,
Predicate<Player> runtimeEligibility,
FellingObserver observer,
Consumer<Exception> failureHandler) {
this.scheduler = scheduler;
this.automaticBreaks = automaticBreaks;
this.runtimeEligibility = runtimeEligibility;
this.observer = observer;
this.failureHandler = failureHandler;
}
@Override
public boolean start(
Player player,
Block initiatingBlock,
TreeStructure tree,
int delayTicks) {
UUID playerId = player.getUniqueId();
if (delayTicks < 1 || activePlayers.containsKey(playerId)) {
return false;
}
List<BlockWork> work = tree.trunkBlocks().stream()
.filter(point -> !point.equals(ORIGIN))
.sorted(Comparator.comparingInt(BlockPoint::y)
.thenComparingInt(BlockPoint::x)
.thenComparingInt(BlockPoint::z))
.map(point -> {
Block block = initiatingBlock.getRelative(point.x(), point.y(), point.z());
return new BlockWork(block, block.getType());
})
.toList();
Set<WorldBlockKey> claims = new HashSet<>();
claims.add(WorldBlockKey.from(initiatingBlock));
work.stream().map(BlockWork::block).map(WorldBlockKey::from).forEach(claims::add);
if (claims.stream().anyMatch(claimedBlocks::contains)) {
return false;
}
Session session = new Session(
player,
initiatingBlock.getWorld(),
new ArrayDeque<>(work),
new ArrayList<>(List.of(FelledBlockSnapshot.capture(initiatingBlock))),
claims,
delayTicks);
claimedBlocks.addAll(claims);
activePlayers.put(playerId, session);
if (work.isEmpty()) {
complete(session);
} else {
scheduleNext(session);
}
return true;
}
public void cancel(UUID playerId) {
Session session = activePlayers.get(playerId);
if (session != null) {
complete(session);
}
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
cancel(event.getPlayer().getUniqueId());
}
@EventHandler
public void onWorldUnload(WorldUnloadEvent event) {
UUID worldId = event.getWorld().getUID();
for (Session session : List.copyOf(activePlayers.values())) {
if (session.world.getUID().equals(worldId)) {
complete(session);
}
}
}
@Override
public void close() {
for (UUID playerId : List.copyOf(activePlayers.keySet())) {
cancel(playerId);
}
}
private void scheduleNext(Session session) {
session.scheduled = scheduler.schedule(() -> processNext(session), session.delayTicks);
}
private void processNext(Session session) {
if (activePlayers.get(session.player.getUniqueId()) != session || !canContinue(session)) {
complete(session);
return;
}
BlockWork next = session.pending.remove();
Block block = next.block();
if (block.getType() != next.expectedMaterial()
|| !session.world.isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) {
complete(session);
return;
}
FelledBlockSnapshot snapshot = FelledBlockSnapshot.capture(block);
boolean broken;
automaticBreaks.mark(block);
try {
broken = session.player.breakBlock(block);
} catch (RuntimeException exception) {
failureHandler.accept(exception);
broken = false;
} finally {
automaticBreaks.unmark(block);
}
if (!broken) {
complete(session);
return;
}
session.removed.add(snapshot);
if (session.pending.isEmpty()) {
complete(session);
} else {
scheduleNext(session);
}
}
private boolean canContinue(Session session) {
return session.player.isOnline()
&& session.player.getWorld().equals(session.world)
&& runtimeEligibility.test(session.player)
&& TreeTools.isAxe(session.player.getInventory().getItemInMainHand().getType());
}
private void complete(Session session) {
if (activePlayers.remove(session.player.getUniqueId(), session)) {
if (session.scheduled != null) {
session.scheduled.cancel();
}
claimedBlocks.removeAll(session.claims);
observer.onFelling(session.player, List.copyOf(session.removed));
}
}
private record BlockWork(Block block, Material expectedMaterial) {
}
private static final class Session {
private final Player player;
private final World world;
private final Queue<BlockWork> pending;
private final List<FelledBlockSnapshot> removed;
private final Set<WorldBlockKey> claims;
private final int delayTicks;
private ScheduledHandle scheduled;
private Session(
Player player,
World world,
Queue<BlockWork> pending,
List<FelledBlockSnapshot> removed,
Set<WorldBlockKey> claims,
int delayTicks) {
this.player = player;
this.world = world;
this.pending = pending;
this.removed = removed;
this.claims = claims;
this.delayTicks = delayTicks;
}
}
}
@@ -0,0 +1,27 @@
package games.dmg.treefeller;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.block.Block;
/** Original state needed to account for and later restore a felled trunk block. */
public record FelledBlockSnapshot(
UUID worldId,
String worldName,
int x,
int y,
int z,
Material material,
String blockData) {
public static FelledBlockSnapshot capture(Block block) {
return new FelledBlockSnapshot(
block.getWorld().getUID(),
block.getWorld().getName(),
block.getX(),
block.getY(),
block.getZ(),
block.getType(),
block.getBlockData().getAsString());
}
}
@@ -0,0 +1,10 @@
package games.dmg.treefeller;
import java.util.List;
import org.bukkit.entity.Player;
/** Receives the exact blocks removed when a felling completes or stops. */
@FunctionalInterface
public interface FellingObserver {
void onFelling(Player player, List<FelledBlockSnapshot> removedBlocks);
}
@@ -0,0 +1,13 @@
package games.dmg.treefeller;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/** Runtime-only record of one player's latest felling. */
public record FellingRecord(
UUID playerId, Instant felledAt, List<FelledBlockSnapshot> blocks) {
public FellingRecord {
blocks = List.copyOf(blocks);
}
}
@@ -0,0 +1,37 @@
package games.dmg.treefeller;
import java.time.Clock;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
/** In-memory, one-record-per-player felling history for undo. */
public final class LastFellingStore implements FellingObserver {
private final Clock clock;
private final Map<UUID, FellingRecord> records = new HashMap<>();
public LastFellingStore(Clock clock) {
this.clock = clock;
}
@Override
public void onFelling(Player player, List<FelledBlockSnapshot> removedBlocks) {
if (!removedBlocks.isEmpty()) {
records.put(
player.getUniqueId(),
new FellingRecord(player.getUniqueId(), Instant.now(clock), removedBlocks));
}
}
public Optional<FellingRecord> get(UUID playerId) {
return Optional.ofNullable(records.get(playerId));
}
public void remove(UUID playerId) {
records.remove(playerId);
}
}
@@ -1,6 +1,7 @@
package games.dmg.treefeller;
import java.nio.file.Path;
import java.time.Clock;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
@@ -13,6 +14,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
private AutomaticBreakRegistry automaticBreakRegistry;
private TreeDetector treeDetector;
private ProgressBossBarObserver progressBossBarObserver;
private AnimatedTreeFellingEngine fellingEngine;
private LastFellingStore lastFellingStore;
@Override
public void onEnable() {
@@ -42,6 +45,26 @@ public final class TreeFellerPlugin extends JavaPlugin {
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
getServer().getPluginManager().registerEvents(progressListener, this);
lastFellingStore = new LastFellingStore(Clock.systemUTC());
fellingEngine = new AnimatedTreeFellingEngine(
(task, delayTicks) -> getServer().getScheduler()
.runTaskLater(this, task, delayTicks)::cancel,
automaticBreakRegistry,
player -> playerStateRepository.load(player.getUniqueId())
.filter(state -> state.enabled() && !state.locked())
.isPresent(),
lastFellingStore,
exception -> getLogger().log(
Level.SEVERE, "Unable to continue animated tree felling", exception));
TreeFellingListener fellingListener = new TreeFellingListener(
treeDetector,
playerStateRepository,
automaticBreakRegistry,
fellingEngine,
() -> settingsService.current().animationDelayTicks());
getServer().getPluginManager().registerEvents(fellingEngine, this);
getServer().getPluginManager().registerEvents(fellingListener, this);
TreeFellerCommand playerCommand = new TreeFellerCommand(
playerStateRepository,
exception -> getLogger().log(
@@ -60,6 +83,9 @@ public final class TreeFellerPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (fellingEngine != null) {
fellingEngine.close();
}
if (progressBossBarObserver != null) {
progressBossBarObserver.close();
}
@@ -80,4 +106,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
public TreeDetector treeDetector() {
return treeDetector;
}
public LastFellingStore lastFellingStore() {
return lastFellingStore;
}
}
@@ -0,0 +1,56 @@
package games.dmg.treefeller;
import java.util.Optional;
import java.util.function.IntSupplier;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
/** Starts automatic felling after an eligible unlocked tree chop. */
public final class TreeFellingListener implements Listener {
private final TreeDetector detector;
private final PlayerStateStore states;
private final AutomaticBreakRegistry automaticBreaks;
private final TreeFellingStarter starter;
private final IntSupplier animationDelay;
public TreeFellingListener(
TreeDetector detector,
PlayerStateStore states,
AutomaticBreakRegistry automaticBreaks,
TreeFellingStarter starter,
IntSupplier animationDelay) {
this.detector = detector;
this.states = states;
this.automaticBreaks = automaticBreaks;
this.starter = starter;
this.animationDelay = animationDelay;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
Player player = event.getPlayer();
if (event.isCancelled()
|| player.getGameMode() != GameMode.SURVIVAL
|| player.isSneaking()
|| !TreeTools.isAxe(player.getInventory().getItemInMainHand().getType())
|| automaticBreaks.isMarked(event.getBlock())) {
return;
}
PlayerTreeFellerState state = states.load(player.getUniqueId())
.orElseGet(() -> PlayerTreeFellerState.initial(
player.getUniqueId(), player.getName()));
if (!state.enabled() || state.locked()) {
return;
}
Optional<TreeStructure> detected = detector.detect(event.getBlock());
if (detected.isEmpty() || !state.isUnlocked(detected.orElseThrow().species())) {
return;
}
starter.start(
player, event.getBlock(), detected.orElseThrow(), animationDelay.getAsInt());
}
}
@@ -0,0 +1,14 @@
package games.dmg.treefeller;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
/** Starts one animated tree-felling operation. */
@FunctionalInterface
public interface TreeFellingStarter {
boolean start(
Player player,
Block initiatingBlock,
TreeStructure tree,
int delayTicks);
}
@@ -0,0 +1,12 @@
package games.dmg.treefeller;
import java.util.UUID;
import org.bukkit.block.Block;
/** Stable runtime identity for a world block. */
public record WorldBlockKey(UUID worldId, int x, int y, int z) {
public static WorldBlockKey from(Block block) {
return new WorldBlockKey(
block.getWorld().getUID(), block.getX(), block.getY(), block.getZ());
}
}