diff --git a/TODO.md b/TODO.md index eb602eb..9c411b7 100644 --- a/TODO.md +++ b/TODO.md @@ -233,8 +233,8 @@ ### Duels (перенос из `dev.efnilite.ipp`) -- **122. Duels — модель «генератор на игрока».** Сейчас наш `ParkourGenerator` = один скорер на сессию (общий `GeneratorState`/history/score, `PlayerInteractionHandler.checkAllPlayers` скорит всех по одной трассе) — это несовместимо с дуэлью. Схема IP-Reborn: `DuelsGenerator` (владелец сессии) держит `Map`, каждый `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`, 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 |invite`. Сложность: **L**. - **126. Duels — не повторить баги:** Race (TIME-лидерборд, ресет в воздухе через `runLater`) — у Duels лидерборда нет, время брать из `state.start` своим форматтером, ресет только синхронно с null-guard; Coop (рекурсия, невозможность создать) — не вызывать `ParkourUser.leave` поверх unregister, сессию создавать в `create()`. diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index d88b212..bfd5fc3 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -117,7 +117,7 @@ - + diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index 1638c64..f1e0ce0 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -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(); } diff --git a/src/main/java/dev/loki/loparkour/duels/DuelsCountdown.java b/src/main/java/dev/loki/loparkour/duels/DuelsCountdown.java new file mode 100644 index 0000000..b53700f --- /dev/null +++ b/src/main/java/dev/loki/loparkour/duels/DuelsCountdown.java @@ -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 = "*D929"; + private static final String DEFAULT_COLOR = "E120"; + + 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 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); + } + } +} diff --git a/src/main/java/dev/loki/loparkour/duels/DuelsGenerator.java b/src/main/java/dev/loki/loparkour/duels/DuelsGenerator.java new file mode 100644 index 0000000..aded3f5 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/duels/DuelsGenerator.java @@ -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 playerGenerators = new LinkedHashMap<>(); + public final int goal; + public final int islandDistance; + final Map 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> 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; + } +} diff --git a/src/main/java/dev/loki/loparkour/duels/DuelsMode.java b/src/main/java/dev/loki/loparkour/duels/DuelsMode.java new file mode 100644 index 0000000..4b09930 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/duels/DuelsMode.java @@ -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); + } +} diff --git a/src/main/java/dev/loki/loparkour/duels/SingleDuelsGenerator.java b/src/main/java/dev/loki/loparkour/duels/SingleDuelsGenerator.java new file mode 100644 index 0000000..2a9c2c3 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/duels/SingleDuelsGenerator.java @@ -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. + * + *

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.

+ */ +public class SingleDuelsGenerator extends ParkourGenerator { + + private static final List 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 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; + } +} diff --git a/src/main/java/dev/loki/loparkour/mode/base/Modes.java b/src/main/java/dev/loki/loparkour/mode/base/Modes.java index 681d820..c1d15e3 100644 --- a/src/main/java/dev/loki/loparkour/mode/base/Modes.java +++ b/src/main/java/dev/loki/loparkour/mode/base/Modes.java @@ -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"); } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d138d5e..d2357bf 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index da29cee..4346cc4 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -72,6 +72,24 @@ play: name: "�D4FF&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: "�D4FF&lDuels" + lore: "" + go: "7FF55&lGO!" + overview: "&#FFD700&l%s &#A0A0A0won the duel! Time: &#FFFFFF%s" + place: "HB3#%s &#A0A0A0%s 񢩈- &#A0A0A0%s" + victory: "7FF55&lVICTORY!" + loss: "&#FF4444&lDEFEAT" + community: name: "&#FFFFFFCommunity" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index ca31c83..0d2e0f2 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -71,6 +71,24 @@ play: name: "�D4FF&l%s" lore: "" + multi: + name: "&#FFFFFFМульти" + + duels: + item: + material: "nether_star" + name: "&#FF6464&lДуэли" + lore: "&#A0A0A0Гонка до цели!||&#A0A0A0У каждого игрока своя трасса" + start: + material: "white_banner" + name: "�D4FF&lДуэли" + lore: "" + go: "7FF55&lВПЕРЁД!" + overview: "&#FFD700&l%s &#A0A0A0победил в дуэли! Время: &#FFFFFF%s" + place: "HB3#%s &#A0A0A0%s 񢩈- &#A0A0A0%s" + victory: "7FF55&lПОБЕДА!" + loss: "&#FF4444&lПОРАЖЕНИЕ" + community: name: "&#FFFFFFСообщество"