feat(teleport): add warmup particles and fading departure clouds
This commit is contained in:
@@ -47,7 +47,7 @@ Navigation particles appear only in the base's world and when the player is more
|
||||
|
||||
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
|
||||
|
||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
|
||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. Home and visitor warm-ups surround the player with nearby-visible floating particles that grow denser as departure approaches. Successful warmed-up teleports leave a cloud at the departure point that thins out over two seconds; cancellation or failure stops emission without a departure cloud.
|
||||
|
||||
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any Survival player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. Entering the flight portal immediately starts the player flying and displays “Pocket dimension flight enabled!” once, without repeating while they stand inside the portal. The owner's automatic-flight preference does not affect portal-granted flight.
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Warm-up particles float around the player, are visible nearby, and grow denser with warm-up progress.
|
||||
- [x] Successful warmed-up teleports leave a departure cloud that thins out over two seconds.
|
||||
- [x] Cancellation stops particle emission and failed teleports create no departure cloud.
|
||||
- [x] Particle effects are bounded, cleaned up on shutdown, and regression-tested without changing teleport timing, safety, or cooldowns.
|
||||
|
||||
- [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
|
||||
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
|
||||
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
|
||||
|
||||
@@ -11,6 +11,10 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Visitor warm-ups share the nearby-visible, progressively denser floating particles used by home teleports.
|
||||
- [x] Successful visitor teleports leave a two-second fading departure cloud; cancellation or failure stops emission without a departure cloud.
|
||||
- [x] Effects are bounded and cleaned up on shutdown, with regression coverage preserving visitor timing, safety, and cooldowns.
|
||||
|
||||
- [x] Base IV requires Base III and an established base.
|
||||
- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
|
||||
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
|
||||
|
||||
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
|
||||
private final VisitorPolicy visitorPolicy;
|
||||
private final SafeBaseDestination destinationFinder;
|
||||
private final Clock clock;
|
||||
private final TeleportParticles particles;
|
||||
private final Map<UUID, Request> requests = new HashMap<>();
|
||||
|
||||
BaseTeleportManager(
|
||||
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
|
||||
SafeBaseDestination destinationFinder,
|
||||
Clock clock
|
||||
) {
|
||||
this(plugin, stateManager, policy, visitorPolicy, destinationFinder, clock,
|
||||
new TeleportParticles(plugin));
|
||||
}
|
||||
|
||||
BaseTeleportManager(
|
||||
Plugin plugin,
|
||||
BaseStateManager stateManager,
|
||||
TeleportPolicy policy,
|
||||
VisitorPolicy visitorPolicy,
|
||||
SafeBaseDestination destinationFinder,
|
||||
Clock clock,
|
||||
TeleportParticles particles
|
||||
) {
|
||||
this.particles = particles;
|
||||
this.plugin = plugin;
|
||||
this.stateManager = stateManager;
|
||||
this.policy = policy;
|
||||
@@ -138,6 +153,7 @@ final class BaseTeleportManager implements Listener {
|
||||
return;
|
||||
}
|
||||
requests.put(player.getUniqueId(), request);
|
||||
particles.start(player.getUniqueId(), origin, seconds);
|
||||
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
|
||||
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
|
||||
}
|
||||
@@ -147,6 +163,7 @@ final class BaseTeleportManager implements Listener {
|
||||
request.cancelTask();
|
||||
}
|
||||
requests.clear();
|
||||
particles.clear();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -204,6 +221,9 @@ final class BaseTeleportManager implements Listener {
|
||||
|
||||
private void tick(Player player, Request request) {
|
||||
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
|
||||
if (requests.remove(player.getUniqueId(), request)) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
}
|
||||
request.cancelTask();
|
||||
return;
|
||||
}
|
||||
@@ -225,18 +245,22 @@ final class BaseTeleportManager implements Listener {
|
||||
BaseLocation base = request.destination;
|
||||
World world = Bukkit.getWorld(base.worldId());
|
||||
if (world == null) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
|
||||
return;
|
||||
}
|
||||
Optional<Location> destination = destinationFinder.find(world, base);
|
||||
if (destination.isEmpty()) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
|
||||
return;
|
||||
}
|
||||
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
|
||||
return;
|
||||
}
|
||||
particles.complete(player.getUniqueId());
|
||||
Instant completedAt = clock.instant();
|
||||
stateManager.update(
|
||||
player.getUniqueId(),
|
||||
@@ -260,6 +284,7 @@ final class BaseTeleportManager implements Listener {
|
||||
return;
|
||||
}
|
||||
request.cancelTask();
|
||||
particles.cancel(player.getUniqueId());
|
||||
if (message != null) {
|
||||
player.sendMessage(ChatColor.RED + message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Cosmetic, main-thread effects; never controls teleport timing or eligibility. */
|
||||
final class TeleportParticles {
|
||||
private final Plugin plugin;
|
||||
private final Map<UUID, Effect> effects = new HashMap<>();
|
||||
private BukkitTask task;
|
||||
|
||||
TeleportParticles(Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
void start(UUID playerId, Location origin, int seconds) {
|
||||
effects.put(playerId, new Effect(origin.clone(), Math.max(1L, seconds * 10L)));
|
||||
if (task == null) {
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 0L, 2L);
|
||||
}
|
||||
}
|
||||
|
||||
void complete(UUID playerId) {
|
||||
Effect effect = effects.get(playerId);
|
||||
if (effect != null) {
|
||||
effect.fading = true;
|
||||
effect.age = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void cancel(UUID playerId) {
|
||||
effects.remove(playerId);
|
||||
stopIfEmpty();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
effects.clear();
|
||||
stopIfEmpty();
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
Iterator<Effect> iterator = effects.values().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Effect effect = iterator.next();
|
||||
int count = effect.fading
|
||||
? Math.max(1, 12 - (int) (effect.age * 12 / 20))
|
||||
: 2 + (int) (10 * Math.min(1.0, (double) effect.age / effect.duration));
|
||||
double angle = effect.age * 0.3;
|
||||
Location point = effect.origin.clone().add(
|
||||
Math.cos(angle) * 0.65, 1.0 + Math.sin(angle * 0.5) * 0.45,
|
||||
Math.sin(angle) * 0.65
|
||||
);
|
||||
point.getWorld().spawnParticle(Particle.END_ROD, point, count, 0.3, 0.45, 0.3, 0.01);
|
||||
effect.age++;
|
||||
if (effect.fading && effect.age >= 20) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
stopIfEmpty();
|
||||
}
|
||||
|
||||
private void stopIfEmpty() {
|
||||
if (effects.isEmpty() && task != null) {
|
||||
task.cancel();
|
||||
task = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Effect {
|
||||
private final Location origin;
|
||||
private final long duration;
|
||||
private long age;
|
||||
private boolean fading;
|
||||
|
||||
private Effect(Location origin, long duration) {
|
||||
this.origin = origin;
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,82 @@ import org.bukkit.plugin.Plugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseTeleportManagerTest {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({
|
||||
"false,true,false", "true,true,false", "false,false,false", "true,false,false",
|
||||
"false,false,true", "true,false,true"
|
||||
})
|
||||
void particlesFollowHomeAndVisitorTeleportOutcome(boolean visit, boolean succeeds, boolean cancelled) {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
org.bukkit.Location origin = new org.bukkit.Location(world, 0, 64, 0);
|
||||
org.bukkit.Location target = new org.bukkit.Location(world, 20, 64, 20);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(origin);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.teleport(eq(target), any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class)))
|
||||
.thenReturn(succeeds);
|
||||
PlayerState owner = PlayerState.newPlayer(visit ? UUID.randomUUID() : id, "Owner")
|
||||
.withGrassAndDirtProgress(500, 4)
|
||||
.withBase(new BaseLocation(worldId, "world", 20, 64, 20, 0, 0), Instant.EPOCH)
|
||||
.withVisitorsEnabled(true);
|
||||
BaseStateManager states = mock(BaseStateManager.class);
|
||||
when(states.player(id, "Player")).thenReturn(owner);
|
||||
TeleportPolicy policy = mock(TeleportPolicy.class);
|
||||
when(policy.warmup(owner)).thenReturn(Duration.ofSeconds(1));
|
||||
when(policy.visitorWarmup(owner)).thenReturn(Duration.ofSeconds(1));
|
||||
when(policy.cooldown(owner)).thenReturn(Duration.ZERO);
|
||||
VisitorPolicy visitors = mock(VisitorPolicy.class);
|
||||
SafeBaseDestination finder = mock(SafeBaseDestination.class);
|
||||
when(finder.find(world, owner.base().orElseThrow())).thenReturn(Optional.of(target));
|
||||
TeleportParticles particles = mock(TeleportParticles.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
org.bukkit.scheduler.BukkitScheduler scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
|
||||
.thenReturn(mock(org.bukkit.scheduler.BukkitTask.class));
|
||||
try (org.mockito.MockedStatic<org.bukkit.Bukkit> bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getScheduler).thenReturn(scheduler);
|
||||
bukkit.when(() -> org.bukkit.Bukkit.getWorld(worldId)).thenReturn(world);
|
||||
BaseTeleportManager manager = new BaseTeleportManager(plugin, states, policy,
|
||||
visitors, finder, Clock.systemUTC(), particles);
|
||||
if (visit) manager.startVisit(player, owner); else manager.start(player);
|
||||
verify(particles).start(id, origin, 1);
|
||||
org.mockito.ArgumentCaptor<Runnable> tick = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).runTaskTimer(eq(plugin), tick.capture(), eq(0L), eq(20L));
|
||||
tick.getValue().run();
|
||||
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).teleport(eq(target),
|
||||
any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class));
|
||||
if (cancelled) {
|
||||
org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
manager.onQuit(quit);
|
||||
verify(particles).cancel(id);
|
||||
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
|
||||
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
|
||||
manager.cancelAll();
|
||||
verify(particles).clear();
|
||||
return;
|
||||
}
|
||||
tick.getValue().run();
|
||||
if (succeeds) {
|
||||
verify(particles).complete(id);
|
||||
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).cancel(id);
|
||||
verify(states).saveIfDirty();
|
||||
} else {
|
||||
verify(particles).cancel(id);
|
||||
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
|
||||
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
|
||||
}
|
||||
manager.cancelAll();
|
||||
verify(particles).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
|
||||
UUID visitorId = UUID.randomUUID();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class TeleportParticlesTest {
|
||||
@Test
|
||||
void warmupIntensifiesThenFadesAtOriginalLocationAndStops() {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Server server = mock(Server.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
BukkitTask task = mock(BukkitTask.class);
|
||||
World world = mock(World.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
|
||||
.thenReturn(task);
|
||||
TeleportParticles effects = new TeleportParticles(plugin);
|
||||
UUID id = UUID.randomUUID();
|
||||
Location origin = new Location(world, 10, 64, 20);
|
||||
effects.start(id, origin, 2);
|
||||
origin.setX(999);
|
||||
ArgumentCaptor<Runnable> runnable = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).runTaskTimer(eq(plugin), runnable.capture(), eq(0L), eq(2L));
|
||||
Runnable tick = runnable.getValue();
|
||||
for (int i = 0; i < 21; i++) tick.run();
|
||||
ArgumentCaptor<Integer> counts = ArgumentCaptor.forClass(Integer.class);
|
||||
verify(world, times(21)).spawnParticle(eq(Particle.END_ROD), any(Location.class),
|
||||
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
|
||||
assertTrue(counts.getAllValues().getLast() > counts.getAllValues().getFirst());
|
||||
clearInvocations(world);
|
||||
effects.complete(id);
|
||||
for (int i = 0; i < 21; i++) tick.run();
|
||||
ArgumentCaptor<Location> locations = ArgumentCaptor.forClass(Location.class);
|
||||
counts = ArgumentCaptor.forClass(Integer.class);
|
||||
verify(world, times(20)).spawnParticle(eq(Particle.END_ROD), locations.capture(),
|
||||
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
|
||||
assertTrue(counts.getAllValues().getFirst() > counts.getAllValues().getLast());
|
||||
assertTrue(locations.getAllValues().stream().allMatch(p -> Math.abs(p.getX() - 10) < 2));
|
||||
verify(task).cancel();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationAndShutdownStopWithoutAfterglow() {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Server server = mock(Server.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
BukkitTask task = mock(BukkitTask.class);
|
||||
World world = mock(World.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
|
||||
.thenReturn(task);
|
||||
TeleportParticles effects = new TeleportParticles(plugin);
|
||||
UUID id = UUID.randomUUID();
|
||||
effects.start(id, new Location(world, 0, 64, 0), 30);
|
||||
effects.cancel(id);
|
||||
effects.complete(id);
|
||||
verify(task).cancel();
|
||||
effects.start(id, new Location(world, 0, 64, 0), 30);
|
||||
effects.clear();
|
||||
verify(task, times(2)).cancel();
|
||||
verifyNoInteractions(world);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user