feat: add Duels mode (MultiMode, generator-per-player)

- duels/: DuelsMode (MultiMode), DuelsGenerator (owner: per-player
  SingleDuelsGenerator map, allowJoining, goal), SingleDuelsGenerator
  (getPlayers()=List.of(own player) -> independent course/score; score
  reaches goal -> win), DuelsCountdown (10s countdown + race start)
- win(): sorted scoreboard, victory/loss titles, return to default after 10s;
  leave() only removePlayer (no Coop recursion)
- config modes.duels.{enabled,max-players,goal,island-distance}, locales
  play.multi.duels.* (ru/en), registered in Modes/PluginBootstrap
- checkstyle 0/0, PMD 0, build green
This commit is contained in:
loki5512344 2026-08-05 21:16:00 +02:00
parent 8fde1b579b
commit acb884b70b
Signed by: boba
GPG key ID: 253067914055423B
11 changed files with 494 additions and 3 deletions

View file

@ -7,6 +7,7 @@ import dev.loki.loparkour.command.core.LoParkourCommand;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.duels.DuelsMode;
import dev.loki.loparkour.hook.holo.HoloHook;
import dev.loki.loparkour.hook.papi.PAPIHook;
import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
@ -78,6 +79,7 @@ public final class PluginBootstrap {
Registry.register(new CoopMode());
Registry.register(new GravityShiftMode());
Registry.register(new ElytraMode());
Registry.register(new DuelsMode());
Modes.init();
}

View file

@ -0,0 +1,110 @@
package dev.loki.loparkour.duels;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Map;
/**
* Start countdown for a duels session: 10s timer with titles, then starts the
* race by generating the first parkour block of every player's course.
*/
final class DuelsCountdown {
private static final int COUNTDOWN_SECONDS = 10;
private static final int COUNTDOWN_PERIOD = 20;
private static final int TITLE_STAY = 21;
private static final int COUNTDOWN_COLOR_START = 3;
private static final long BANNER_DELAY_TICKS = 5L;
private static final int FIRST_BLOCK_FORWARD = 6;
private static final double SPAWN_CENTER_OFFSET = 0.5;
private static final int COLOR_RED = 1;
private static final int COLOR_YELLOW = 2;
private static final int COLOR_GREEN = 3;
private static final String RED = "&#DA2626";
private static final String YELLOW = "&#DCD31D";
private static final String GREEN = "&#42D929";
private static final String DEFAULT_COLOR = "&#23E120";
private final DuelsGenerator generator;
private BukkitTask countdownTask;
DuelsCountdown(@NotNull DuelsGenerator generator) {
this.generator = generator;
}
void giveStartItem(@NotNull ParkourPlayer owner) {
Bukkit.getScheduler().runTaskLater(LoParkour.getPlugin(), () -> {
if (owner.player.isOnline()) {
owner.player.getInventory().addItem(Locales.getItem(owner.locale, "play.multi.duels.start").build());
}
}, BANNER_DELAY_TICKS);
}
void start() {
cancel();
final int[] counter = {COUNTDOWN_SECONDS};
countdownTask = Bukkit.getScheduler().runTaskTimer(LoParkour.getPlugin(),
() -> tick(counter), 0L, COUNTDOWN_PERIOD);
}
void cancel() {
if (countdownTask != null) {
countdownTask.cancel();
countdownTask = null;
}
}
void tick(@NotNull int[] counter) {
if (generator.state.stopped) {
cancel();
return;
}
final int current = counter[0]--;
if (current == 0) {
startRace();
return;
}
if (current <= COUNTDOWN_COLOR_START) {
sendCountdownTitle(current);
}
}
void startRace() {
generator.allowJoining = false;
for (final Map.Entry<ParkourPlayer, SingleDuelsGenerator> entry : generator.playerGenerators.entrySet()) {
final Location ps = generator.playerSpawns.get(entry.getKey());
if (ps == null) {
continue;
}
final Vector heading = entry.getValue().state.heading;
final Location bs = ps.clone().add(FIRST_BLOCK_FORWARD * heading.getX() - SPAWN_CENTER_OFFSET, 0,
FIRST_BLOCK_FORWARD * heading.getZ() - SPAWN_CENTER_OFFSET);
entry.getValue().generateFirst(ps, bs);
}
for (final ParkourPlayer pp : new ArrayList<>(generator.playerGenerators.keySet())) {
pp.player.sendTitle(ColorUtil.color(Locales.getString(pp.locale, "play.multi.duels.go")), "", 0, TITLE_STAY, 0);
}
cancel();
}
void sendCountdownTitle(int current) {
final String color = switch (current) {
case COLOR_RED -> RED;
case COLOR_YELLOW -> YELLOW;
case COLOR_GREEN -> GREEN;
default -> DEFAULT_COLOR;
};
for (final ParkourPlayer pp : new ArrayList<>(generator.playerGenerators.keySet())) {
pp.player.sendTitle(ColorUtil.color(color + current), "", 0, TITLE_STAY, 0);
}
}
}

View file

@ -0,0 +1,167 @@
package dev.loki.loparkour.duels;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Coordinator of a duels session: owns the session generator, builds one
* {@link SingleDuelsGenerator} per player and drives them every tick.
*/
public class DuelsGenerator extends ParkourGenerator {
private static final long WIN_RETURN_DELAY_TICKS = 200L;
private static final int PLATFORM_RADIUS = 1;
private static final double SPAWN_CENTER_OFFSET = 0.5;
private static final int DEFAULT_GOAL = 50;
private static final int DEFAULT_ISLAND_DISTANCE = 20;
private static final int VICTORY_FADE_IN = 1;
private static final int VICTORY_STAY = 100;
private static final int VICTORY_FADE_OUT = 10;
public boolean allowJoining = true;
public final ParkourPlayer owner;
public final Map<ParkourPlayer, SingleDuelsGenerator> playerGenerators = new LinkedHashMap<>();
public final int goal;
public final int islandDistance;
final Map<ParkourPlayer, Location> playerSpawns = new LinkedHashMap<>();
private final DuelsCountdown countdown;
public DuelsGenerator(@NotNull Session session) {
super(session);
this.goal = Config.CONFIG.getInt("modes.duels.goal", DEFAULT_GOAL);
this.islandDistance = Config.CONFIG.getInt("modes.duels.island-distance", DEFAULT_ISLAND_DISTANCE);
this.owner = session.getPlayers().get(0);
this.countdown = new DuelsCountdown(this);
addPlayer(owner);
countdown.giveStartItem(owner);
}
@Override
public void generateFirst(Location spawn, Location block) {
playerSpawns.put(owner, spawn);
countdown.start();
}
@Override
public void startTick() {
}
@Override
public void tick() {
if (!state.stopped) {
playerGenerators.values().forEach(SingleDuelsGenerator::tick);
}
}
public void addPlayer(@NotNull ParkourPlayer pp) {
if (!allowJoining || playerGenerators.containsKey(pp)) {
return;
}
final SingleDuelsGenerator gen = new SingleDuelsGenerator(session);
gen.owningGenerator = this;
gen.setOwnedPlayer(pp);
gen.setPlayerIndex(playerGenerators.size());
gen.state.heading = Option.HEADING.getDirection();
final Location spawnBase = session.getSpawnLocation();
if (spawnBase == null || spawnBase.getWorld() == null) {
return;
}
Location platformCenter = spawnBase;
if (pp != owner) {
platformCenter = spawnBase.clone().add(0, 0, islandDistance * (double) playerGenerators.size());
for (int dx = -PLATFORM_RADIUS; dx <= PLATFORM_RADIUS; dx++) {
for (int dz = -PLATFORM_RADIUS; dz <= PLATFORM_RADIUS; dz++) {
platformCenter.getBlock().getRelative(dx, 0, dz).setType(Material.STONE);
}
}
}
final Location playerSpawn = platformCenter.clone().add(SPAWN_CENTER_OFFSET, 1, SPAWN_CENTER_OFFSET);
playerSpawns.put(pp, playerSpawn);
playerGenerators.put(pp, gen);
pp.player.teleport(playerSpawn);
}
public void removePlayer(@NotNull ParkourPlayer pp) {
final SingleDuelsGenerator gen = playerGenerators.remove(pp);
if (gen == null) {
return;
}
gen.reset(false);
if (pp == owner) {
countdown.cancel();
returnToLobby();
return;
}
if (playerGenerators.size() == 1) {
win(new ArrayList<>(playerGenerators.keySet()).get(0));
}
}
public void win(@NotNull ParkourPlayer winner) {
if (state.stopped) {
return;
}
state.stopped = true;
countdown.cancel();
final String winningName = winner.getName();
final String winningTime = playerGenerators.get(winner).getFormattedTime();
final List<Map.Entry<ParkourPlayer, SingleDuelsGenerator>> leaderboard =
new ArrayList<>(playerGenerators.entrySet());
leaderboard.sort((one, two) -> two.getValue().state.score - one.getValue().state.score);
for (final ParkourPlayer pp : new ArrayList<>(playerGenerators.keySet())) {
pp.send(Locales.getString(pp.locale, "play.multi.duels.overview").formatted(winningName, winningTime));
for (int i = 0; i < leaderboard.size(); i++) {
pp.send(Locales.getString(pp.locale, "play.multi.duels.place")
.formatted(i + 1, leaderboard.get(i).getKey().getName(), leaderboard.get(i).getValue().state.score));
}
pp.player.sendTitle(ColorUtil.color(Locales.getString(pp.locale,
pp == winner ? "play.multi.duels.victory" : "play.multi.duels.loss")), "", VICTORY_FADE_IN, VICTORY_STAY, VICTORY_FADE_OUT);
}
Bukkit.getScheduler().runTaskLater(LoParkour.getPlugin(), () -> returnToLobby(), WIN_RETURN_DELAY_TICKS);
}
@Override
public void reset(boolean regenerate) {
if (regenerate) {
return;
}
countdown.cancel();
for (final ParkourPlayer pp : new ArrayList<>(playerGenerators.keySet())) {
removePlayer(pp);
}
}
public int getGoal() {
return goal;
}
private void returnToLobby() {
for (final ParkourPlayer pp : new ArrayList<>(playerGenerators.keySet())) {
if (pp.player.isOnline() && playerGenerators.containsKey(pp)) {
Modes.DEFAULT.create(pp.player);
}
}
}
@Override
public Mode getMode() {
return Modes.DUELS;
}
}

View file

@ -0,0 +1,81 @@
package dev.loki.loparkour.duels;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Item;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Duels mode: every player gets their own parkour course, first to the goal wins.
*/
public class DuelsMode implements MultiMode {
@Override
@NotNull
public String getName() {
return "duels";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "play.multi.duels.item");
}
@Override
@Nullable
public Leaderboard getLeaderboard() {
return null;
}
@Override
public int getMaxPlayers() {
return Config.CONFIG.getInt("modes.duels.max-players", 2);
}
@Override
public void create(Player player) {
final ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof DuelsGenerator) {
return;
}
player.closeInventory();
final int maxPlayers = getMaxPlayers();
Session.create(DuelsGenerator::new,
session -> session.getPlayers().size() < maxPlayers && ((DuelsGenerator) session.generator).allowJoining,
null,
player);
}
@Override
public void join(Player player, Session session) {
if (!session.isAcceptingPlayers()) {
return;
}
player.closeInventory();
final ParkourPlayer pp = ParkourUser.register(player, session);
session.addPlayers(pp);
((DuelsGenerator) session.generator).addPlayer(pp);
}
@Override
public void leave(Player player, Session session) {
final ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp == null || session.getPlayers().isEmpty()) {
return;
}
((DuelsGenerator) session.generator).removePlayer(pp);
}
}

View file

@ -0,0 +1,86 @@
package dev.loki.loparkour.duels;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.session.core.Session;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* One parkour course per duels player, isolated from everyone else.
*
* <p>Scoring only looks at the owned player: {@link #getPlayers()} returns just
* the owned player, so {@code state.history}/{@code state.score} belong to that
* single course. The owning {@link DuelsGenerator} ticks every instance.</p>
*/
public class SingleDuelsGenerator extends ParkourGenerator {
private static final List<Material> COLORS = List.of(
Material.LIGHT_BLUE_CONCRETE, Material.RED_CONCRETE, Material.LIME_CONCRETE, Material.YELLOW_CONCRETE,
Material.ORANGE_CONCRETE, Material.BLUE_CONCRETE, Material.MAGENTA_CONCRETE, Material.WHITE_CONCRETE,
Material.LIGHT_GRAY_CONCRETE, Material.BLACK_CONCRETE, Material.GREEN_CONCRETE, Material.BROWN_CONCRETE,
Material.CYAN_CONCRETE, Material.PURPLE_CONCRETE, Material.GRAY_CONCRETE, Material.PINK_CONCRETE);
public DuelsGenerator owningGenerator;
private ParkourPlayer ownedPlayer;
private BlockData blockData;
private boolean won;
public SingleDuelsGenerator(@NotNull Session session) {
super(session);
}
public void setOwnedPlayer(@NotNull ParkourPlayer pp) {
this.ownedPlayer = pp;
}
public void setPlayerIndex(int index) {
this.blockData = COLORS.get(index % COLORS.size()).createBlockData();
}
@Override
@NotNull
public List<ParkourPlayer> getPlayers() {
return ownedPlayer == null ? List.of() : List.of(ownedPlayer);
}
@Override
public void startTick() {
}
@Override
public void tick() {
super.tick();
checkWin();
}
@Override
protected void score() {
super.score();
checkWin();
}
/**
* Duels has no persistent leaderboard; falling must not register anything.
*/
@Override
public void registerScore(String time, String difficulty, int score) {
}
private void checkWin() {
if (!won && owningGenerator != null && state.score >= owningGenerator.goal) {
won = true;
owningGenerator.win(ownedPlayer);
}
}
@Override
public Mode getMode() {
return Modes.DUELS;
}
}

View file

@ -1,6 +1,7 @@
package dev.loki.loparkour.mode.base;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.duels.DuelsMode;
import dev.loki.loparkour.elytra.mode.ElytraMode;
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.multi.CoopMode;
@ -23,6 +24,7 @@ public final class Modes {
public static CoopMode COOP;
public static GravityShiftMode GRAVITY_SHIFT;
public static ElytraMode ELYTRA;
public static DuelsMode DUELS;
public static void init() {
DEFAULT = (DefaultMode) Registry.getMode("default");
@ -33,5 +35,6 @@ public final class Modes {
COOP = (CoopMode) Registry.getMode("coop");
GRAVITY_SHIFT = (GravityShiftMode) Registry.getMode("gravity-shift");
ELYTRA = (ElytraMode) Registry.getMode("elytra");
DUELS = (DuelsMode) Registry.getMode("duels");
}
}