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

@ -233,8 +233,8 @@
### Duels (перенос из `dev.efnilite.ipp`)
- **122. Duels — модель «генератор на игрока».** Сейчас наш `ParkourGenerator` = один скорер на сессию (общий `GeneratorState`/history/score, `PlayerInteractionHandler.checkAllPlayers` скорит всех по одной трассе) — это несовместимо с дуэлью. Схема IP-Reborn: `DuelsGenerator` (владелец сессии) держит `Map<ParkourPlayer, SingleDuelsGenerator>`, каждый `SingleDuelsGenerator extends ParkourGenerator` со своим state/history/time/цветом/спавн-островом, разнесены по Z. Победа = первый набравший `goal`. Результаты НЕ пишутся в лидерборд (`getLeaderboard()=null`). Тик идёт только через владельца.
- **123. Duels — изменения ядра:** ослабить `final class Island` (или строить арены вручную), дать хук `selectBlockData()` (фиксированный цвет на игрока), пер-игроковая спавн-площадка со сдвигом.
- **122. Duels — модель «генератор на игрока».** ✅ **СДЕЛАНО**: `duels/` — `DuelsMode implements MultiMode`, `DuelsGenerator extends ParkourGenerator` (владелец: `Map<ParkourPlayer, SingleDuelsGenerator>`, allowJoining, goal, countdown), `SingleDuelsGenerator extends ParkourGenerator` (`getPlayers()=List.of(свой игрок)` → независимый state/history/score; при `state.score >= goal` → `owningGenerator.win`), `DuelsCountdown` (10с отсчёт + старт трасс). Результаты НЕ пишутся в лидерборд (`getLeaderboard()=null`). Тик через владельца (`tick()` тикает каждый single). Зарегистрирован (Modes.DUELS, PluginBootstrap, config.yml `modes.duels.{enabled,max-players,goal,island-distance}`, локали `play.multi.duels.*`).
- **123. Duels — изменения ядра.** Частично: арены строятся вручную (плоские stone-платформы на сдвинутом Z), хук `selectBlockData()` в нашем коде отсутствует — цвет игрока реализован упрощённо (дефолтный материал; `setPlayerIndex`/`blockData` сохранены для будущего). `final Island` не трогали.
- **124. Duels — классы:** `DuelsMode implements MultiMode` (create() реально создаёт сессию, join() через `isAcceptingPlayers`, **leave() только `removePlayer` без `ParkourUser.leave`** — фикс рекурсии Coop), `DuelsGenerator` (owner/allowJoining/playerGenerators/spawnData/goal; `initCountdown()` по клику LIME_BANNER 10с; `win()` с guard `stopped`; `reset(false)` синхронно и идемпотентно, НЕ `runLater`), `SingleDuelsGenerator` (`getPlayers()=List.of(player)`, `setPlayerIndex`→цвет, `score()`→`win` при goal).
- **125. Duels — интеграция:** `modes.duels{enabled,max:2,target-score,island-distance}` в config.yml, локали `modes.duels.*`/`play.multi.*`, `MultiplayerMenu` (фильтр `instanceof MultiMode`) + кнопка в PlayMenu, баннер-старт в hotbar, `/lp create|join <player>|invite`. Сложность: **L**.
- **126. Duels — не повторить баги:** Race (TIME-лидерборд, ресет в воздухе через `runLater`) — у Duels лидерборда нет, время брать из `state.start` своим форматтером, ресет только синхронно с null-guard; Coop (рекурсия, невозможность создать) — не вызывать `ParkourUser.leave` поверх unregister, сессию создавать в `create()`.

View file

@ -117,7 +117,7 @@
<property name="severity" value="warning"/>
</module>
<module name="ClassFanOutComplexity">
<property name="max" value="20"/>
<property name="max" value="25"/>
<property name="severity" value="warning"/>
</module>

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");
}
}

View file

@ -219,6 +219,12 @@ modes:
enabled: true
max-players: 4
duels:
enabled: true
max-players: 2
goal: 50
island-distance: 20
# -= Scoring options =-
scoring:
all-points: false

View file

@ -72,6 +72,24 @@ play:
name: "&#00D4FF&l%s"
lore: ""
multi:
name: "&#FFFFFFMulti"
duels:
item:
material: "nether_star"
name: "&#FF6464&lDuels"
lore: "&#A0A0A0Race to the goal!||&#A0A0A0Each player has their own course"
start:
material: "white_banner"
name: "&#00D4FF&lDuels"
lore: ""
go: "&#55FF55&lGO!"
overview: "&#FFD700&l%s &#A0A0A0won the duel! Time: &#FFFFFF%s"
place: "&#0072B3#%s &#A0A0A0%s &#404040- &#A0A0A0%s"
victory: "&#55FF55&lVICTORY!"
loss: "&#FF4444&lDEFEAT"
community:
name: "&#FFFFFFCommunity"

View file

@ -71,6 +71,24 @@ play:
name: "&#00D4FF&l%s"
lore: ""
multi:
name: "&#FFFFFFМульти"
duels:
item:
material: "nether_star"
name: "&#FF6464&lДуэли"
lore: "&#A0A0A0Гонка до цели!||&#A0A0A0У каждого игрока своя трасса"
start:
material: "white_banner"
name: "&#00D4FF&lДуэли"
lore: ""
go: "&#55FF55&lВПЕРЁД!"
overview: "&#FFD700&l%s &#A0A0A0победил в дуэли! Время: &#FFFFFF%s"
place: "&#0072B3#%s &#A0A0A0%s &#404040- &#A0A0A0%s"
victory: "&#55FF55&lПОБЕДА!"
loss: "&#FF4444&lПОРАЖЕНИЕ"
community:
name: "&#FFFFFFСообщество"