feat(teleport): add warmup particles and fading departure clouds
This commit is contained in:
@@ -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