feat(gameplay): implement hook sabotage, generator kick, jump listener, and boss bar UI

- Add HookSabotage system for survivors to disable hooks temporarily
- Add GeneratorKick killer ability to regress generator progress
- Add PlayerJumpListener to track survivor jump actions
- Add BossBarManager for displaying game state and objectives
- Update generator regression logic to support killer kick mechanic
- Update KillerController to integrate generator kick ability
- Update PlayerInteractListener to handle new interaction types
- Refactor game phases to use BossBarManager for UI updates
- Update .gitignore to exclude compiled bin/ directory
- Simplify PLAN.md documentation for easier maintenance
This commit is contained in:
loki5512344 2026-06-04 12:11:04 +02:00
parent 3d6204cc83
commit 89dc9eb00d
78 changed files with 2293 additions and 1210 deletions

View file

@ -11,42 +11,42 @@ public class GameConfig {
}
private void setDefaults() {
// Lobby settings
setDefault("lobby.min-players", 5);
// Lobby
setDefault("lobby.min-players", 4);
setDefault("lobby.max-players", 5);
setDefault("lobby.countdown-seconds", 60);
setDefault("lobby.countdown-4-players", 45);
setDefault("lobby.countdown-5-players", 15);
setDefault("lobby.role-assign-delay", 5);
setDefault("lobby.server-lobby-count", 3);
setDefault("lobby.persist-lobbies", true);
// Generator settings
// Players scale
setDefault("players.survivor-scale", 1.0);
setDefault("players.killer-scale", 1.5);
// Generators
setDefault("generators.total-count", 7);
setDefault("generators.required-count", 5);
setDefault("generators.repair-seconds", 80);
setDefault("generators.repair-seconds", 90);
setDefault("generators.regression-rate", 0.25);
setDefault("generators.kick-regression-rate", 2.5);
setDefault("generators.kick-regression-percent", 20.0);
setDefault("generators.kick-duration-seconds", 30);
setDefault("generators.kick-cooldown-seconds", 30);
setDefault("generators.kick-charge-seconds", 3);
setDefault("generators.interaction-range", 3.0);
// Skill check settings (legacy - moved to skillcheck.yml)
setDefault("skillcheck.zone-size", 0.15);
setDefault("skillcheck.rotation-speed", 3.0);
setDefault("skillcheck.success-bonus", 5.0);
setDefault("skillcheck.fail-penalty", 10.0);
// Colors for UI (&#rrggbb format support)
setDefault("colors.progress-bar-complete", "&#00FF00");
setDefault("colors.progress-bar-incomplete", "&#FF0000");
setDefault("colors.progress-bar-background", "&#333333");
setDefault("colors.hp-healthy", "&#00FF00");
setDefault("colors.hp-injured", "&#FFA500");
setDefault("colors.hp-downed", "&#FF0000");
setDefault("colors.hp-dead", "&#8B0000");
// Hook settings
// Hooks
setDefault("hooks.stage1-seconds", 60);
setDefault("hooks.stage2-seconds", 60);
setDefault("hooks.struggle-interval", 2);
setDefault("hooks.max-hooks", 3);
setDefault("hooks.interaction-range", 3.0);
setDefault("hooks.rescue-seconds", 3);
setDefault("hooks.sabotage-seconds", 10);
setDefault("hooks.sabotage-recovery-seconds", 60);
// Movement speeds (% of normal)
// Movement
setDefault("movement.killer", 115);
setDefault("movement.killer-carry", 92);
setDefault("movement.survivor-walk", 100);
@ -55,30 +55,66 @@ public class GameConfig {
setDefault("movement.exhaustion-duration", 3);
setDefault("movement.exhaustion-cooldown", 5);
// Healing settings
// Healing
setDefault("healing.other-heal-seconds", 15);
setDefault("healing.self-heal-seconds", 30);
setDefault("healing.other-heal-seconds", 16);
setDefault("healing.medkit-bonus", 0.5);
setDefault("healing.interrupt-on-move", true);
setDefault("healing.max-heals-per-match", 4);
setDefault("healing.heal-range", 3.0);
// Killer settings
// Carry / escape
setDefault("carry.escape-jumps", 15);
setDefault("carry.escape-speed-seconds", 3);
setDefault("carry.jump-fill-percent", 6.67);
// Killer
setDefault("killer.attack-cooldown", 2.5);
setDefault("killer.attack-range", 3.0);
setDefault("killer.pallet-stun-seconds", 2.5);
setDefault("killer.pallet-stun-seconds", 2.0);
setDefault("killer.pallet-break-seconds", 3.0);
setDefault("killer.terror-radius", 32);
setDefault("killer.pallet-interaction-range", 3.0);
// BossBar
setDefault("bossbar.enabled", true);
setDefault("bossbar.title", "<red>☠ МАНЬЯК РЯДОМ ☠</red>");
setDefault("bossbar.color", "RED");
setDefault("bossbar.style", "SOLID");
setDefault("bossbar.appear-range", 32);
setDefault("bossbar.full-range", 8);
// Window vault
setDefault("window.vault-survivor-fast", 1.0);
setDefault("window.vault-survivor-slow", 1.5);
setDefault("window.vault-killer", 1.5);
setDefault("window.vault-block-seconds", 15);
// Endgame
setDefault("endgame.gate-open-seconds", 20);
setDefault("endgame.collapse-seconds", 120);
setDefault("endgame.chest-open-seconds", 3);
setDefault("endgame.gate-interaction-range", 3.0);
// Killer notification
setDefault("killer-notification.compass-duration-seconds", 5);
// Scoreboard
setDefault("scoreboard.show-hearts", true);
setDefault("scoreboard.heart-symbol", "♥");
setDefault("scoreboard.color-healthy", "WHITE");
setDefault("scoreboard.color-injured", "RED");
setDefault("scoreboard.color-healing", "GREEN");
setDefault("scoreboard.color-hooked", "DARK_RED");
// Colors for UI
setDefault("colors.progress-bar-complete", "&#00FF00");
setDefault("colors.progress-bar-incomplete", "&#FF0000");
setDefault("colors.progress-bar-background", "&#333333");
// Hatch
setDefault("hatch.min-survivors-for-open", 1);
setDefault("hatch.require-more-gens-than-survivors", true);
}
private void setDefault(String path, Object value) {
@ -87,99 +123,130 @@ public class GameConfig {
}
}
public int getMinPlayers() {
return config.getInt("lobby.min-players", 5);
}
// -------------------------------------------------------------------------
// Lobby
// -------------------------------------------------------------------------
public int getMinPlayers() { return config.getInt("lobby.min-players", 4); }
public int getMaxPlayers() { return config.getInt("lobby.max-players", 5); }
public int getCountdown4Players() { return config.getInt("lobby.countdown-4-players", 45); }
public int getCountdown5Players() { return config.getInt("lobby.countdown-5-players", 15); }
/** @deprecated Use getCountdown4Players() / getCountdown5Players() */
@Deprecated
public int getLobbyCountdown() { return getCountdown4Players(); }
public int getRoleAssignDelay() { return config.getInt("lobby.role-assign-delay", 5); }
public int getServerLobbyCount() { return config.getInt("lobby.server-lobby-count", 3); }
public boolean isPersistLobbies() { return config.getBoolean("lobby.persist-lobbies", true); }
public int getMaxPlayers() {
return config.getInt("lobby.max-players", 5);
}
// -------------------------------------------------------------------------
// Players
// -------------------------------------------------------------------------
public double getSurvivorScale() { return config.getDouble("players.survivor-scale", 1.0); }
public double getKillerScale() { return config.getDouble("players.killer-scale", 1.5); }
public int getLobbyCountdown() {
return config.getInt("lobby.countdown-seconds", 60);
}
// -------------------------------------------------------------------------
// Generators
// -------------------------------------------------------------------------
public int getGeneratorTotal() { return config.getInt("generators.total-count", 7); }
public int getGeneratorRequired() { return config.getInt("generators.required-count", 5); }
public int getGeneratorRepairSeconds() { return config.getInt("generators.repair-seconds", 90); }
public double getRegressionRate() { return config.getDouble("generators.regression-rate", 0.25); }
public double getKickRegressionRate() { return config.getDouble("generators.kick-regression-rate", 2.5); }
public double getKickRegressionPercent() { return config.getDouble("generators.kick-regression-percent", 20.0); }
public int getKickDuration() { return config.getInt("generators.kick-duration-seconds", 30); }
public int getKickCooldownSeconds() { return config.getInt("generators.kick-cooldown-seconds", 30); }
public int getKickChargeSeconds() { return config.getInt("generators.kick-charge-seconds", 3); }
public double getGeneratorInteractionRange() { return config.getDouble("generators.interaction-range", 3.0); }
public int getRoleAssignDelay() {
return config.getInt("lobby.role-assign-delay", 5);
}
// -------------------------------------------------------------------------
// Hooks
// -------------------------------------------------------------------------
public int getStage1Seconds() { return config.getInt("hooks.stage1-seconds", 60); }
public int getStage2Seconds() { return config.getInt("hooks.stage2-seconds", 60); }
public int getStruggleInterval() { return config.getInt("hooks.struggle-interval", 2); }
public int getMaxHooks() { return config.getInt("hooks.max-hooks", 3); }
public double getHookInteractionRange() { return config.getDouble("hooks.interaction-range", 3.0); }
public int getRescueSeconds() { return config.getInt("hooks.rescue-seconds", 3); }
public int getSabotageSeconds() { return config.getInt("hooks.sabotage-seconds", 10); }
public int getSabotageRecoverySeconds() { return config.getInt("hooks.sabotage-recovery-seconds", 60); }
public int getGeneratorTotal() {
return config.getInt("generators.total-count", 7);
}
// -------------------------------------------------------------------------
// Movement
// -------------------------------------------------------------------------
public double getKillerSpeed() { return config.getDouble("movement.killer", 115) / 100.0; }
public double getKillerCarrySpeed() { return config.getDouble("movement.killer-carry", 92) / 100.0; }
public double getSurvivorWalkSpeed() { return config.getDouble("movement.survivor-walk", 100) / 100.0; }
public double getSurvivorSprintSpeed() { return config.getDouble("movement.survivor-sprint", 150) / 100.0; }
public double getSurvivorCrawlSpeed() { return config.getDouble("movement.survivor-crawl", 30) / 100.0; }
public int getExhaustionDuration() { return config.getInt("movement.exhaustion-duration", 3); }
public int getExhaustionCooldown() { return config.getInt("movement.exhaustion-cooldown", 5); }
public int getGeneratorRequired() {
return config.getInt("generators.required-count", 5);
}
// -------------------------------------------------------------------------
// Healing
// -------------------------------------------------------------------------
public int getOtherHealSeconds() { return config.getInt("healing.other-heal-seconds", 15); }
public int getSelfHealSeconds() { return config.getInt("healing.self-heal-seconds", 30); }
public double getMedkitBonus() { return config.getDouble("healing.medkit-bonus", 0.5); }
public boolean isHealInterruptOnMove() { return config.getBoolean("healing.interrupt-on-move", true); }
public int getMaxHealsPerMatch() { return config.getInt("healing.max-heals-per-match", 4); }
public double getHealRange() { return config.getDouble("healing.heal-range", 3.0); }
public int getGeneratorRepairSeconds() {
return config.getInt("generators.repair-seconds", 80);
}
// -------------------------------------------------------------------------
// Carry / Escape
// -------------------------------------------------------------------------
public int getCarryEscapeJumps() { return config.getInt("carry.escape-jumps", 15); }
public int getCarryEscapeSpeedSeconds() { return config.getInt("carry.escape-speed-seconds", 3); }
public double getCarryJumpFillPercent() { return config.getDouble("carry.jump-fill-percent", 6.67); }
public double getRegressionRate() {
return config.getDouble("generators.regression-rate", 0.25);
}
// -------------------------------------------------------------------------
// Killer
// -------------------------------------------------------------------------
public int getAttackCooldown() { return (int)(config.getDouble("killer.attack-cooldown", 2.5) * 20); }
public double getAttackRange() { return config.getDouble("killer.attack-range", 3.0); }
public int getPalletStunTicks() { return (int)(config.getDouble("killer.pallet-stun-seconds", 2.0) * 20); }
public int getPalletBreakTicks() { return (int)(config.getDouble("killer.pallet-break-seconds", 3.0) * 20); }
public int getTerrorRadius() { return config.getInt("killer.terror-radius", 32); }
public double getPalletInteractionRange() { return config.getDouble("killer.pallet-interaction-range", 3.0); }
public double getKickRegressionRate() {
return config.getDouble("generators.kick-regression-rate", 2.5);
}
// -------------------------------------------------------------------------
// BossBar
// -------------------------------------------------------------------------
public boolean isBossBarEnabled() { return config.getBoolean("bossbar.enabled", true); }
public String getBossBarTitle() { return config.getString("bossbar.title", "<red>☠ МАНЬЯК РЯДОМ ☠</red>"); }
public String getBossBarColor() { return config.getString("bossbar.color", "RED"); }
public String getBossBarStyle() { return config.getString("bossbar.style", "SOLID"); }
public int getBossBarAppearRange() { return config.getInt("bossbar.appear-range", 32); }
public int getBossBarFullRange() { return config.getInt("bossbar.full-range", 8); }
public int getKickDuration() {
return config.getInt("generators.kick-duration-seconds", 30);
}
// -------------------------------------------------------------------------
// Window
// -------------------------------------------------------------------------
public int getVaultSurvivorFastTicks() { return (int)(config.getDouble("window.vault-survivor-fast", 1.0) * 20); }
public int getVaultSurvivorSlowTicks() { return (int)(config.getDouble("window.vault-survivor-slow", 1.5) * 20); }
public int getVaultKillerTicks() { return (int)(config.getDouble("window.vault-killer", 1.5) * 20); }
public int getVaultBlockSeconds() { return config.getInt("window.vault-block-seconds", 15); }
public int getStage1Seconds() {
return config.getInt("hooks.stage1-seconds", 60);
}
// -------------------------------------------------------------------------
// Endgame
// -------------------------------------------------------------------------
public int getGateOpenSeconds() { return config.getInt("endgame.gate-open-seconds", 20); }
public int getCollapseSeconds() { return config.getInt("endgame.collapse-seconds", 120); }
public int getChestOpenSeconds() { return config.getInt("endgame.chest-open-seconds", 3); }
public double getGateInteractionRange() { return config.getDouble("endgame.gate-interaction-range", 3.0); }
public int getStage2Seconds() {
return config.getInt("hooks.stage2-seconds", 60);
}
// -------------------------------------------------------------------------
// Killer notification
// -------------------------------------------------------------------------
public int getCompassDurationSeconds() { return config.getInt("killer-notification.compass-duration-seconds", 5); }
public int getMaxHooks() {
return config.getInt("hooks.max-hooks", 3);
}
// -------------------------------------------------------------------------
// Scoreboard
// -------------------------------------------------------------------------
public boolean isShowHearts() { return config.getBoolean("scoreboard.show-hearts", true); }
public String getHeartSymbol() { return config.getString("scoreboard.heart-symbol", "♥"); }
public String getColorHealthy() { return config.getString("scoreboard.color-healthy", "WHITE"); }
public String getColorInjured() { return config.getString("scoreboard.color-injured", "RED"); }
public String getColorHealing() { return config.getString("scoreboard.color-healing", "GREEN"); }
public String getColorHooked() { return config.getString("scoreboard.color-hooked", "DARK_RED"); }
public double getKillerSpeed() {
return config.getDouble("movement.killer", 115) / 100.0;
}
public double getKillerCarrySpeed() {
return config.getDouble("movement.killer-carry", 92) / 100.0;
}
public double getSurvivorWalkSpeed() {
return config.getDouble("movement.survivor-walk", 100) / 100.0;
}
public double getSurvivorSprintSpeed() {
return config.getDouble("movement.survivor-sprint", 150) / 100.0;
}
public double getSurvivorCrawlSpeed() {
return config.getDouble("movement.survivor-crawl", 30) / 100.0;
}
public int getAttackCooldown() {
return (int) (config.getDouble("killer.attack-cooldown", 2.5) * 20);
}
public double getAttackRange() {
return config.getDouble("killer.attack-range", 3.0);
}
public int getPalletStunTicks() {
return (int) (config.getDouble("killer.pallet-stun-seconds", 2.5) * 20);
}
public int getPalletBreakTicks() {
return (int) (config.getDouble("killer.pallet-break-seconds", 3.0) * 20);
}
public int getTerrorRadius() {
return config.getInt("killer.terror-radius", 32);
}
public FileConfiguration getConfig() {
return config;
}
public FileConfiguration getConfig() { return config; }
}

View file

@ -94,7 +94,10 @@ public class GameSession {
return new GameplayPhase(generatorManager);
}
if (currentPhase instanceof GameplayPhase) {
return new EndgamePhase();
return new EndgamePhase(
plugin.getConfigManager().getGame().getCollapseSeconds(),
gateManager, null, null
);
}
return null;
}

View file

@ -8,6 +8,7 @@ public class ExitGate {
private final Location location;
private GateState state;
private double progress;
private double savedProgress; // сохраняется при уходе игрока
private boolean powered;
public enum GateState {
@ -20,6 +21,7 @@ public class ExitGate {
this.location = location;
this.state = GateState.CLOSED;
this.progress = 0.0;
this.savedProgress = 0.0;
this.powered = false;
}
@ -72,7 +74,8 @@ public class ExitGate {
public void updateProgress(double delta) {
if (state != GateState.OPENING) return;
progress += delta;
// Линейная установка — delta это абсолютный шаг, не additive accumulation
progress = Math.min(1.0, progress + delta);
if (progress >= 1.0) {
progress = 1.0;
state = GateState.OPEN;
@ -80,9 +83,29 @@ public class ExitGate {
}
}
/** Прямая установка прогресса (для линейного расчёта в GateOpening). */
public void setProgressDirect(double value) {
if (state != GateState.OPENING) return;
progress = Math.min(1.0, Math.max(0.0, value));
if (progress >= 1.0) {
progress = 1.0;
state = GateState.OPEN;
playOpenedSound();
}
}
public double getSavedProgress() { return savedProgress; }
/** Сохранить текущий прогресс (вызывается когда игрок уходит от ворот) */
public void saveProgress() { savedProgress = progress; }
/** Восстановить сохранённый прогресс (вызывается когда игрок подходит снова) */
public void restoreProgress() { progress = savedProgress; }
public void reset() {
this.state = GateState.CLOSED;
this.progress = 0.0;
this.savedProgress = 0.0;
this.powered = false;
}

View file

@ -75,9 +75,10 @@ public class GateManager {
public void activateEndgame() {
endgameActive = true;
// Только разблокировать ворота — игрок должен открывать их вручную
for (ExitGate gate : gates) {
gate.setPowered(true);
gate.startOpening();
// НЕ вызываем gate.startOpening() — ворота открываются игроком
}
}

View file

@ -68,6 +68,9 @@ public class GateOpening {
Player player = survivor.getPlayer();
if (player == null) return;
// Восстановить сохранённый прогресс
gate.restoreProgress();
openingPlayers.put(survivor.getUuid(), new OpeningProgress(gate, 0));
player.sendMessage("§aОткрываем ворота... (зажмите ПКМ)");
@ -115,6 +118,7 @@ public class GateOpening {
public void run() {
Player player = survivor.getPlayer();
if (player == null || !player.isOnline()) {
gate.saveProgress();
stopOpening(survivor);
cancel();
return;
@ -127,6 +131,7 @@ public class GateOpening {
}
if (!gate.isInRange(player.getLocation(), INTERACTION_RADIUS)) {
gate.saveProgress();
stopOpening(survivor);
cancel();
return;
@ -135,10 +140,13 @@ public class GateOpening {
progress.ticks++;
playOpeningEffect(player, gate, progress.ticks);
double progressPct = progress.ticks / (double) openTicks;
gate.updateProgress(progressPct);
// Линейный прогресс: устанавливаем абсолютное значение от стартовой точки
double progressPct = gate.getSavedProgress()
+ (1.0 - gate.getSavedProgress()) * (progress.ticks / (double) openTicks);
// Используем setProgress вместо additive updateProgress
gate.setProgressDirect(progressPct);
if (progress.ticks >= openTicks) {
if (progressPct >= 1.0) {
completeOpening(survivor, gate);
cancel();
}

View file

@ -13,6 +13,7 @@ public class Hook {
private int stageProgress;
private boolean occupied;
private boolean basement;
private boolean disabled; // саботирован выжившим
public Hook(UUID id, Location location, boolean basement) {
this.id = id;
@ -67,4 +68,6 @@ public class Hook {
public HookStage getCurrentStage() { return currentStage; }
public boolean isOccupied() { return occupied; }
public boolean isBasement() { return basement; }
public boolean isDisabled() { return disabled; }
public void setDisabled(boolean disabled) { this.disabled = disabled; }
}

View file

@ -2,6 +2,7 @@ package dev.loki.hook;
import dev.loki.config.LocationsConfig;
import dev.loki.mdbd;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import org.bukkit.Location;
import org.bukkit.scheduler.BukkitTask;
@ -12,12 +13,17 @@ public class HookManager {
private final mdbd plugin;
private final Map<UUID, Hook> hooks;
private BukkitTask tickTask;
private int maxHooks = 3; // default, overridden from config
public HookManager(mdbd plugin) {
this.plugin = plugin;
this.hooks = new HashMap<>();
}
public void setMaxHooks(int max) {
this.maxHooks = max;
}
public void loadHooks(LocationsConfig config) {
hooks.clear();
List<Location> hookLocations = config.getHooks();
@ -50,7 +56,7 @@ public class HookManager {
public Optional<Hook> findAvailableHook() {
return hooks.values().stream()
.filter(h -> !h.isOccupied())
.filter(h -> !h.isOccupied() && !h.isDisabled())
.min(Comparator.comparingInt(h -> h.isBasement() ? 1 : 0));
}
@ -58,7 +64,7 @@ public class HookManager {
Hook nearest = null;
double minDist = maxDistance;
for (Hook hook : hooks.values()) {
if (hook.isOccupied()) continue;
if (hook.isOccupied() || hook.isDisabled()) continue;
double dist = hook.getLocation().distance(location);
if (dist < minDist) {
minDist = dist;
@ -83,6 +89,18 @@ public class HookManager {
}
public boolean tryHookSurvivor(Survivor survivor, Location killerLoc) {
// Проверяем лимит крюков → если превышен, сразу смерть
if (survivor.getHooksTaken() >= maxHooks) {
survivor.setState(PlayerState.DEAD);
var p = survivor.getPlayer();
if (p != null) {
p.sendMessage(net.kyori.adventure.text.Component.text(
"[DBD] Ваши жизни исчерпаны. Вы погибли.",
net.kyori.adventure.text.format.NamedTextColor.DARK_RED));
}
return true; // "повешен" = завершён
}
Hook nearest = findNearestUnoccupiedHook(killerLoc, 3.0);
if (nearest == null) {
nearest = findAvailableHook().orElse(null);

View file

@ -1,28 +1,37 @@
package dev.loki.hook;
import dev.loki.config.GameConfig;
import dev.loki.mdbd;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import dev.loki.ui.ActionBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scheduler.BukkitTask;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Hook rescue mechanic: rescuer must hold Shift near the hook for
* config rescue-seconds (default 3) without moving away.
* After success the victim gets Speed I for 5 seconds.
*/
public class HookRescue {
private final mdbd plugin;
private final ActionBar actionBar;
private final Map<UUID, RescueSession> activeRescues;
private static final int RESCUE_DURATION_TICKS = 40;
private static final double RESCUE_RANGE = 3.0;
private final GameConfig config;
private final Map<UUID, RescueSession> activeRescues = new HashMap<>();
public HookRescue(mdbd plugin, ActionBar actionBar) {
this.plugin = plugin;
this.actionBar = actionBar;
this.activeRescues = new HashMap<>();
public HookRescue(mdbd plugin, ActionBar actionBar, GameConfig config) {
this.plugin = plugin;
this.actionBar = actionBar;
this.config = config;
}
public boolean startRescue(Survivor rescuer, Hook hook) {
@ -30,22 +39,24 @@ public class HookRescue {
if (activeRescues.containsKey(uuid)) return false;
if (!hook.isOccupied() || hook.getHookedSurvivor() == null) return false;
Location hookLoc = hook.getLocation();
Player player = rescuer.getPlayer();
if (player == null || player.getLocation().distance(hookLoc) > RESCUE_RANGE) {
return false;
}
double rescueRange = config.getHookInteractionRange();
if (player == null || player.getLocation().distance(hook.getLocation()) > rescueRange) return false;
RescueSession session = new RescueSession(rescuer, hook, RESCUE_DURATION_TICKS);
// Rescuer cannot rescue themselves
if (hook.getHookedSurvivor().getUuid().equals(rescuer.getUuid())) return false;
int durationTicks = config.getRescueSeconds() * 20;
RescueSession session = new RescueSession(rescuer, hook, durationTicks);
activeRescues.put(uuid, session);
BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(
plugin,
() -> tickRescue(uuid),
1L, 1L
);
plugin, () -> tickRescue(uuid), 1L, 1L);
session.setTask(task);
if (player.isOnline()) {
player.sendMessage(Component.text("[DBD] Спасаем... держите Shift!", NamedTextColor.YELLOW));
}
return true;
}
@ -57,20 +68,21 @@ public class HookRescue {
Hook hook = session.getHook();
Player player = rescuer.getPlayer();
if (player == null || !player.isOnline()) {
cancelRescue(uuid);
return;
}
if (player == null || !player.isOnline()) { cancelRescue(uuid); return; }
if (player.getLocation().distance(hook.getLocation()) > RESCUE_RANGE) {
// Must keep sneaking
if (!player.isSneaking()) { cancelRescue(uuid); return; }
double rescueRange = config.getHookInteractionRange();
if (player.getLocation().distance(hook.getLocation()) > rescueRange) {
cancelRescue(uuid);
actionBar.sendMessage(player, "<red>Rescue cancelled - too far!");
player.sendMessage(Component.text("[DBD] Слишком далеко! Спасение прервано.", NamedTextColor.RED));
return;
}
session.incrementProgress();
double percent = session.getProgressPercent();
actionBar.showProgress(player, "Rescuing...", percent);
actionBar.showProgress(player, "Спасение...", percent);
if (session.isComplete()) {
completeRescue(session);
@ -84,17 +96,27 @@ public class HookRescue {
Survivor victim = hook.getHookedSurvivor();
hook.unhookSurvivor();
if (victim != null) {
victim.setState(PlayerState.INJURED);
Player victimPlayer = victim.getPlayer();
if (victimPlayer != null) {
victimPlayer.teleport(hook.getLocation().clone().add(1, 0, 0));
if (victimPlayer != null && victimPlayer.isOnline()) {
// Teleport slightly away from hook
Location freeLoc = hook.getLocation().clone().add(1.0, 0, 0);
victimPlayer.teleport(freeLoc);
// Speed I for 5 seconds after rescue
victimPlayer.addPotionEffect(
new PotionEffect(PotionEffectType.SPEED, 5 * 20, 0, false, true));
victimPlayer.sendMessage(Component.text("[DBD] Вас спасли! Бегите!", NamedTextColor.GREEN));
}
}
Player rescuerPlayer = rescuer.getPlayer();
if (rescuerPlayer != null) {
actionBar.sendMessage(rescuerPlayer, "<green>Rescue complete!");
if (rescuerPlayer != null && rescuerPlayer.isOnline()) {
rescuerPlayer.sendActionBar(Component.text("[DBD] Союзник спасён!", NamedTextColor.GREEN));
rescuerPlayer.sendMessage(Component.text("[DBD] Спасение завершено!", NamedTextColor.GREEN));
}
}
@ -102,41 +124,40 @@ public class HookRescue {
RescueSession session = activeRescues.remove(uuid);
if (session != null) {
session.cancel();
Survivor rescuer = session.getRescuer();
Player player = rescuer.getPlayer();
if (player != null && player.isOnline()) {
player.sendActionBar(Component.empty());
}
}
}
public boolean isRescuing(UUID uuid) {
return activeRescues.containsKey(uuid);
}
public boolean isRescuing(UUID uuid) { return activeRescues.containsKey(uuid); }
public void clear() {
for (RescueSession session : activeRescues.values()) {
session.cancel();
}
for (RescueSession session : activeRescues.values()) session.cancel();
activeRescues.clear();
}
// -------------------------------------------------------------------------
private static class RescueSession {
private final Survivor rescuer;
private final Hook hook;
private final int durationTicks;
private int progressTicks;
private int progressTicks = 0;
private BukkitTask task;
RescueSession(Survivor rescuer, Hook hook, int durationTicks) {
this.rescuer = rescuer;
this.hook = hook;
this.rescuer = rescuer;
this.hook = hook;
this.durationTicks = durationTicks;
this.progressTicks = 0;
}
void incrementProgress() { progressTicks++; }
boolean isComplete() { return progressTicks >= durationTicks; }
double getProgressPercent() { return (double) progressTicks / durationTicks * 100; }
void setTask(BukkitTask task) { this.task = task; }
void cancel() { if (task != null) task.cancel(); }
void setTask(BukkitTask t) { this.task = t; }
void cancel() { if (task != null) { task.cancel(); task = null; } }
Survivor getRescuer() { return rescuer; }
Hook getHook() { return hook; }
}

View file

@ -0,0 +1,150 @@
package dev.loki.hook;
import dev.loki.config.GameConfig;
import dev.loki.player.Survivor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Hook sabotage mechanic:
* - Survivor holds Shift near a hook for sabotage-seconds (10 sec) → hook disabled
* - Disabled hook recovers after sabotage-recovery-seconds (60 sec)
*/
public class HookSabotage {
private final Plugin plugin;
private final GameConfig config;
private final HookManager hookManager;
/** survivorUUID → active sabotage task */
private final Map<UUID, BukkitTask> sabotageCharging = new HashMap<>();
/** hookUUID → recovery task */
private final Map<UUID, BukkitTask> recoveryTasks = new HashMap<>();
public HookSabotage(Plugin plugin, GameConfig config, HookManager hookManager) {
this.plugin = plugin;
this.config = config;
this.hookManager = hookManager;
}
// -------------------------------------------------------------------------
// Public API — called when survivor starts/stops sneaking near a hook
// -------------------------------------------------------------------------
/** Call when survivor starts holding Shift near a hook. */
public boolean startSabotage(Survivor survivor) {
UUID uuid = survivor.getUuid();
if (sabotageCharging.containsKey(uuid)) return false;
Player p = survivor.getPlayer();
if (p == null) return false;
Hook hook = hookManager.findNearestUnoccupiedHook(
p.getLocation(), config.getHookInteractionRange());
if (hook == null || hook.isOccupied() || hook.isDisabled()) return false;
int chargeTicks = config.getSabotageSeconds() * 20;
BukkitTask task = new BukkitRunnable() {
int elapsed = 0;
@Override
public void run() {
Player player = survivor.getPlayer();
if (player == null || !player.isOnline() || !player.isSneaking()) {
stopSabotage(uuid);
cancel();
return;
}
Hook h = hookManager.findNearestUnoccupiedHook(
player.getLocation(), config.getHookInteractionRange());
if (h == null || h != hook) {
stopSabotage(uuid);
cancel();
return;
}
elapsed++;
double pct = (double) elapsed / chargeTicks * 100;
player.sendActionBar(Component.text(
"§e⚙ Саботаж крюка: " + (int) pct + "%"));
if (elapsed >= chargeTicks) {
executeSabotage(survivor, hook);
cancel();
}
}
}.runTaskTimer(plugin, 1L, 1L);
sabotageCharging.put(uuid, task);
if (p.isOnline()) {
p.sendMessage(Component.text("[DBD] Держите Shift для саботажа крюка...",
NamedTextColor.YELLOW));
}
return true;
}
/** Call when survivor releases Shift. */
public void stopSabotage(UUID survivorUuid) {
BukkitTask task = sabotageCharging.remove(survivorUuid);
if (task != null) {
task.cancel();
Player p = Bukkit.getPlayer(survivorUuid);
if (p != null && p.isOnline()) p.sendActionBar(Component.empty());
}
}
public boolean isSabotaging(UUID survivorUuid) {
return sabotageCharging.containsKey(survivorUuid);
}
public void shutdown() {
sabotageCharging.values().forEach(BukkitTask::cancel);
sabotageCharging.clear();
recoveryTasks.values().forEach(BukkitTask::cancel);
recoveryTasks.clear();
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
private void executeSabotage(Survivor survivor, Hook hook) {
sabotageCharging.remove(survivor.getUuid());
hook.setDisabled(true);
Player p = survivor.getPlayer();
if (p != null && p.isOnline()) {
p.sendActionBar(Component.empty());
p.sendMessage(Component.text("[DBD] Крюк сломан!", NamedTextColor.GREEN));
p.getWorld().playSound(p.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0f, 0.8f);
}
Bukkit.broadcast(Component.text("[DBD] ⚙ Крюк был сломан выжившим!",
NamedTextColor.YELLOW));
// Запускаем восстановление
int recoveryTicks = config.getSabotageRecoverySeconds() * 20;
BukkitTask recovery = new BukkitRunnable() {
@Override
public void run() {
hook.setDisabled(false);
recoveryTasks.remove(hook.getId());
Bukkit.broadcast(Component.text("[DBD] 🔧 Крюк восстановлен.",
NamedTextColor.GRAY));
}
}.runTaskLater(plugin, recoveryTicks);
recoveryTasks.put(hook.getId(), recovery);
}
}

View file

@ -30,6 +30,12 @@ public class HookSacrifice {
survivor.setState(PlayerState.DEAD);
hook.unhookSurvivor();
// Convert to SPECTATOR mode
if (player != null && player.isOnline()) {
player.setGameMode(org.bukkit.GameMode.SPECTATOR);
player.sendMessage(Component.text("[DBD] Вы были принесены в жертву. Наблюдайте за матчем.", NamedTextColor.DARK_RED));
}
announceSacrifice(survivor);
}

View file

@ -1,5 +1,6 @@
package dev.loki.hook;
import dev.loki.config.GameConfig;
import dev.loki.mdbd;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
@ -15,14 +16,22 @@ public class HookStruggle {
private final TitleUI titleUI;
private final Map<UUID, BukkitTask> struggleTasks;
private final Map<UUID, Integer> struggleProgress;
private static final int STRUGGLE_INTERVAL_TICKS = 40;
private final int struggleIntervalTicks;
private static final int STRUGGLE_WINDOW_TICKS = 20;
public HookStruggle(mdbd plugin, TitleUI titleUI) {
this(plugin, titleUI, null);
}
public HookStruggle(mdbd plugin, TitleUI titleUI, GameConfig config) {
this.plugin = plugin;
this.titleUI = titleUI;
this.struggleTasks = new HashMap<>();
this.struggleProgress = new HashMap<>();
// Читаем из конфига: struggle-interval в секундах → тики
this.struggleIntervalTicks = (config != null)
? config.getStruggleInterval() * 20
: 40; // дефолт 2 сек
}
public void startStruggle(Survivor survivor, Hook hook) {
@ -32,8 +41,8 @@ public class HookStruggle {
BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(
plugin,
() -> triggerSkillCheck(survivor, hook),
STRUGGLE_INTERVAL_TICKS,
STRUGGLE_INTERVAL_TICKS
struggleIntervalTicks,
struggleIntervalTicks
);
struggleTasks.put(uuid, task);
struggleProgress.put(uuid, 0);

View file

@ -0,0 +1,175 @@
package dev.loki.killer;
import dev.loki.config.GameConfig;
import dev.loki.objective.Generator;
import dev.loki.objective.GeneratorManager;
import dev.loki.player.Killer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Killer generator kick: Killer holds Shift near a generator for
* kick-charge-seconds (default 3), then the generator loses
* kick-regression-percent (default 20%) progress.
*
* A shared cooldown of kick-cooldown-seconds applies to ALL generators.
*/
public class GeneratorKick {
private final Plugin plugin;
private final GameConfig config;
private final GeneratorManager generatorManager;
/** UUID → ongoing charging task */
private final Map<UUID, BukkitTask> chargingTasks = new HashMap<>();
/** Shared cooldown across all generators: remaining ticks */
private int globalCooldownTicks = 0;
private BukkitTask cooldownTask;
public GeneratorKick(Plugin plugin, GameConfig config, GeneratorManager generatorManager) {
this.plugin = plugin;
this.config = config;
this.generatorManager = generatorManager;
}
// -------------------------------------------------------------------------
// Public API — called when killer starts/stops sneaking near a generator
// -------------------------------------------------------------------------
/** Call when killer starts holding Shift near a generator. */
public boolean startCharging(Killer killer) {
UUID uuid = killer.getUuid();
if (chargingTasks.containsKey(uuid)) return false; // already charging
if (globalCooldownTicks > 0) {
Player p = killer.getPlayer();
if (p != null) {
int secsLeft = (int) Math.ceil(globalCooldownTicks / 20.0);
p.sendMessage(Component.text("[DBD] Кулдаун удара: " + secsLeft + " сек", NamedTextColor.RED));
}
return false;
}
Player p = killer.getPlayer();
if (p == null) return false;
Generator gen = generatorManager.getNearestGenerator(p.getLocation(),
config.getGeneratorInteractionRange());
if (gen == null || !gen.canBeRepaired()) return false;
int chargeTicks = config.getKickChargeSeconds() * 20;
BukkitTask task = new BukkitRunnable() {
int elapsed = 0;
@Override
public void run() {
Player player = killer.getPlayer();
if (player == null || !player.isOnline() || !player.isSneaking()) {
stopCharging(killer.getUuid());
cancel();
return;
}
// Still near the same generator?
Generator g = generatorManager.getNearestGenerator(player.getLocation(),
config.getGeneratorInteractionRange());
if (g == null || g != gen) {
stopCharging(killer.getUuid());
cancel();
return;
}
elapsed++;
double pct = (double) elapsed / chargeTicks * 100;
player.sendActionBar(Component.text("§c⚡ Разрушение: " + (int) pct + "%"));
if (elapsed >= chargeTicks) {
executeKick(killer, gen);
cancel();
}
}
}.runTaskTimer(plugin, 1L, 1L);
chargingTasks.put(uuid, task);
if (p.isOnline()) p.sendMessage(Component.text("[DBD] Держите Shift для разрушения генератора...", NamedTextColor.RED));
return true;
}
/** Call when killer releases Shift or moves away. */
public void stopCharging(UUID killerUuid) {
BukkitTask task = chargingTasks.remove(killerUuid);
if (task != null) {
task.cancel();
Player p = Bukkit.getPlayer(killerUuid);
if (p != null && p.isOnline()) {
p.sendActionBar(Component.empty());
}
}
}
public boolean isCharging(UUID killerUuid) {
return chargingTasks.containsKey(killerUuid);
}
public void shutdown() {
for (BukkitTask t : chargingTasks.values()) t.cancel();
chargingTasks.clear();
if (cooldownTask != null) { cooldownTask.cancel(); cooldownTask = null; }
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
private void executeKick(Killer killer, Generator gen) {
chargingTasks.remove(killer.getUuid());
// Remove progress
double regPct = config.getKickRegressionPercent();
double current = gen.getProgress();
double newProg = Math.max(0, current - regPct);
gen.setProgress(newProg);
gen.setUnderRegression(true, config.getKickDuration() * 20);
Player p = killer.getPlayer();
if (p != null && p.isOnline()) {
p.sendActionBar(Component.empty());
p.sendMessage(Component.text("[DBD] Генератор поврежден! -" + (int) regPct + "% прогресса", NamedTextColor.RED));
p.getWorld().playSound(p.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.6f, 1.3f);
}
// Notify all players nearby
Bukkit.broadcast(Component.text("[DBD] ⚡ Маньяк разрушил генератор!", NamedTextColor.DARK_RED));
// Start global cooldown
startGlobalCooldown();
}
private void startGlobalCooldown() {
globalCooldownTicks = config.getKickCooldownSeconds() * 20;
if (cooldownTask != null) { cooldownTask.cancel(); }
cooldownTask = new BukkitRunnable() {
@Override
public void run() {
if (globalCooldownTicks > 0) {
globalCooldownTicks -= 20;
} else {
globalCooldownTicks = 0;
cancel();
cooldownTask = null;
}
}
}.runTaskTimer(plugin, 20L, 20L);
}
}

View file

@ -2,30 +2,45 @@ package dev.loki.killer;
import dev.loki.config.GameConfig;
import dev.loki.player.Killer;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scheduler.BukkitTask;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Manages the Killer carrying a downed Survivor.
*
* Escape mechanic: Survivor spams SPACE (jump) to fill the escape bar.
* When the bar reaches 100% the Survivor breaks free, is teleported
* slightly forward and receives Speed I for carry.escape-speed-seconds.
*/
public class KillerCarry {
private final Plugin plugin;
private final GameConfig config;
private final Map<UUID, CarryData> activeCarries;
private final Map<UUID, CarryData> activeCarries = new HashMap<>();
private BukkitTask carryTask;
// Separate map: killerUUID -> carried survivor UUID (for jump listener)
private final Map<UUID, UUID> killerToSurvivor = new HashMap<>();
private static class CarryData {
final Killer killer;
final Survivor survivor;
double wiggleProgress;
double wiggleProgress; // 0..100
CarryData(Killer killer, Survivor survivor) {
this.killer = killer;
this.killer = killer;
this.survivor = survivor;
this.wiggleProgress = 0;
}
@ -34,22 +49,35 @@ public class KillerCarry {
public KillerCarry(Plugin plugin, GameConfig config) {
this.plugin = plugin;
this.config = config;
this.activeCarries = new HashMap<>();
}
public boolean tryPickup(Killer killer, Survivor survivor) {
if (!canPickup(killer, survivor)) {
return false;
// -------------------------------------------------------------------------
// Called by PlayerJumpListener when a survivor jumps
// -------------------------------------------------------------------------
public void onSurvivorJump(UUID survivorUuid) {
for (CarryData data : activeCarries.values()) {
if (data.survivor.getUuid().equals(survivorUuid)) {
data.wiggleProgress += config.getCarryJumpFillPercent();
showWiggleProgress(data);
if (data.wiggleProgress >= 100) {
Bukkit.getScheduler().runTask(plugin, () -> forceDrop(data.killer));
}
return;
}
}
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
public boolean tryPickup(Killer killer, Survivor survivor) {
if (!canPickup(killer, survivor)) return false;
Player killerPlayer = killer.getPlayer();
Player survivorPlayer = survivor.getPlayer();
if (killerPlayer == null || survivorPlayer == null) return false;
if (!HitValidator.isValidCarryPosition(killerPlayer, survivorPlayer)) {
return false;
}
if (!HitValidator.isValidCarryPosition(killerPlayer, survivorPlayer)) return false;
startCarry(killer, survivor);
return true;
@ -57,112 +85,25 @@ public class KillerCarry {
public boolean canPickup(Killer killer, Survivor survivor) {
if (killer.isCarrying()) return false;
if (!survivor.getState().name().equals("DOWNED")) return false;
Player killerPlayer = killer.getPlayer();
Player survivorPlayer = survivor.getPlayer();
return killerPlayer != null && survivorPlayer != null && survivorPlayer.isOnline();
}
private void startCarry(Killer killer, Survivor survivor) {
killer.setCarrying(true);
Player survivorPlayer = survivor.getPlayer();
if (survivorPlayer != null) {
survivorPlayer.setInvisible(true);
}
CarryData data = new CarryData(killer, survivor);
activeCarries.put(killer.getUuid(), data);
if (carryTask == null) {
startCarryTask();
}
if (survivor.getState() != PlayerState.DOWNED) return false;
Player kp = killer.getPlayer();
Player sp = survivor.getPlayer();
return kp != null && sp != null && sp.isOnline();
}
public boolean drop(Killer killer) {
CarryData data = activeCarries.remove(killer.getUuid());
killerToSurvivor.remove(killer.getUuid());
if (data == null) return false;
stopCarry(data);
stopCarry(data, false);
return true;
}
private void stopCarry(CarryData data) {
data.killer.setCarrying(false);
Player survivorPlayer = data.survivor.getPlayer();
if (survivorPlayer != null) {
survivorPlayer.setInvisible(false);
survivorPlayer.teleport(data.killer.getPlayer().getLocation());
}
if (activeCarries.isEmpty() && carryTask != null) {
carryTask.cancel();
carryTask = null;
}
}
private void startCarryTask() {
carryTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
activeCarries.values().removeIf(data -> {
if (!isValidCarry(data)) {
stopCarry(data);
return true;
}
updateCarryPosition(data);
updateWiggle(data);
return false;
});
}, 1L, 1L);
}
private boolean isValidCarry(CarryData data) {
Player killerPlayer = data.killer.getPlayer();
Player survivorPlayer = data.survivor.getPlayer();
if (killerPlayer == null || survivorPlayer == null) return false;
if (!killerPlayer.isOnline() || !survivorPlayer.isOnline()) return false;
return true;
}
private void updateCarryPosition(CarryData data) {
Player killerPlayer = data.killer.getPlayer();
Player survivorPlayer = data.survivor.getPlayer();
Location carryLoc = killerPlayer.getLocation().clone().add(0, -0.5, 0);
survivorPlayer.teleport(carryLoc);
}
private void updateWiggle(CarryData data) {
Player survivorPlayer = data.survivor.getPlayer();
if (survivorPlayer == null) return;
if (survivorPlayer.isSneaking()) {
data.wiggleProgress += 1.5;
}
if (data.wiggleProgress >= 100) {
forceDrop(data.killer);
}
}
public void forceDrop(Killer killer) {
CarryData data = activeCarries.remove(killer.getUuid());
killerToSurvivor.remove(killer.getUuid());
if (data == null) return;
Player survivorPlayer = data.survivor.getPlayer();
if (survivorPlayer != null) {
Location dropLoc = survivorPlayer.getLocation();
dropLoc.add(killer.getPlayer().getLocation().getDirection().multiply(1.5));
survivorPlayer.teleport(dropLoc);
}
stopCarry(data);
stopCarry(data, true);
}
public boolean isCarrying(Killer killer) {
@ -180,10 +121,119 @@ public class KillerCarry {
}
public void shutdown() {
if (carryTask != null) {
carryTask.cancel();
if (carryTask != null) { carryTask.cancel(); carryTask = null; }
for (CarryData data : new HashMap<>(activeCarries).values()) {
stopCarry(data, false);
}
activeCarries.values().forEach(this::stopCarry);
activeCarries.clear();
killerToSurvivor.clear();
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
private void startCarry(Killer killer, Survivor survivor) {
killer.setCarrying(true);
survivor.setState(PlayerState.CARRIED);
Player survivorPlayer = survivor.getPlayer();
if (survivorPlayer != null) {
survivorPlayer.setInvisible(true);
survivorPlayer.sendMessage(Component.text(
"[DBD] Нажимайте ПРОБЕЛ чтобы вырваться!", NamedTextColor.YELLOW));
}
CarryData data = new CarryData(killer, survivor);
activeCarries.put(killer.getUuid(), data);
killerToSurvivor.put(killer.getUuid(), survivor.getUuid());
if (carryTask == null) startCarryTask();
}
private void stopCarry(CarryData data, boolean escaped) {
data.killer.setCarrying(false);
Player killerPlayer = data.killer.getPlayer();
Player survivorPlayer = data.survivor.getPlayer();
if (survivorPlayer != null && survivorPlayer.isOnline()) {
survivorPlayer.setInvisible(false);
if (escaped) {
// Drop survivor slightly forward from killer
Location dropLoc = survivorPlayer.getLocation();
if (killerPlayer != null) {
dropLoc = killerPlayer.getLocation().clone()
.add(killerPlayer.getLocation().getDirection().multiply(2.0));
}
survivorPlayer.teleport(dropLoc);
// Speed I
int speedTicks = config.getCarryEscapeSpeedSeconds() * 20;
survivorPlayer.addPotionEffect(
new PotionEffect(PotionEffectType.SPEED, speedTicks, 0, false, true));
survivorPlayer.sendMessage(Component.text(
"[DBD] Вы вырвались!", NamedTextColor.GREEN));
data.survivor.setState(PlayerState.DOWNED); // still downed, just free
} else {
survivorPlayer.teleport(data.killer.getPlayer() != null
? data.killer.getPlayer().getLocation()
: survivorPlayer.getLocation());
data.survivor.setState(PlayerState.DOWNED);
}
// Clear action bar progress
survivorPlayer.sendActionBar(Component.empty());
}
if (activeCarries.isEmpty() && carryTask != null) {
carryTask.cancel();
carryTask = null;
}
}
private void startCarryTask() {
carryTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
if (activeCarries.isEmpty()) return;
for (CarryData data : new HashMap<>(activeCarries).values()) {
if (!isValidCarry(data)) {
activeCarries.remove(data.killer.getUuid());
killerToSurvivor.remove(data.killer.getUuid());
stopCarry(data, false);
continue;
}
updateCarryPosition(data);
showWiggleProgress(data); // refresh bar every tick
}
}, 1L, 1L);
}
private boolean isValidCarry(CarryData data) {
Player kp = data.killer.getPlayer();
Player sp = data.survivor.getPlayer();
return kp != null && sp != null && kp.isOnline() && sp.isOnline();
}
private void updateCarryPosition(CarryData data) {
Player killerPlayer = data.killer.getPlayer();
Player survivorPlayer = data.survivor.getPlayer();
if (killerPlayer == null || survivorPlayer == null) return;
Location carryLoc = killerPlayer.getLocation().clone().add(0, -0.5, 0);
survivorPlayer.teleport(carryLoc);
}
private void showWiggleProgress(CarryData data) {
Player survivorPlayer = data.survivor.getPlayer();
if (survivorPlayer == null) return;
double pct = Math.min(100, data.wiggleProgress);
int filled = (int) (pct / 10);
int empty = 10 - filled;
String bar = "§e[" + "§a█".repeat(filled) + "§7░".repeat(empty) + "§e]";
survivorPlayer.sendActionBar(Component.text("§6Побег: " + bar + " " + (int) pct + "%"));
}
}

View file

@ -32,19 +32,15 @@ public class KillerController {
}
public void initialize() {
attackListener = new AttackListener(playerManager, damageHandler,
(killer, survivor) -> tryAttack(killer, survivor));
attackListener.register(plugin);
// NOTE: AttackListener is registered externally by GameplayPhase to avoid duplication.
// Do NOT register attackListener here.
for (Killer killer : playerManager.getKillers()) {
movement.startTracking(killer);
}
}
public void shutdown() {
if (attackListener != null) {
attackListener.unregister();
}
// attackListener is managed externally — do NOT unregister here
attack.shutdown();
carry.shutdown();
movement.shutdown();

View file

@ -12,6 +12,7 @@ import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerToggleSneakEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
@ -33,6 +34,8 @@ public class PlayerInteractListener implements Listener {
private BiConsumer<Player, Boolean> onRescueAttempt;
private BiConsumer<Player, Boolean> onChestInteract;
private BiConsumer<Player, Boolean> onHatchInteract;
/** Called when killer toggles sneak: (player, isSneaking) */
private BiConsumer<Player, Boolean> onKillerSneak;
private final Map<UUID, Long> lastClick = new ConcurrentHashMap<>();
private static final long COOLDOWN_MS = 200;
@ -53,6 +56,16 @@ public class PlayerInteractListener implements Listener {
public void setOnRescueAttempt(BiConsumer<Player, Boolean> h) { this.onRescueAttempt = h; }
public void setOnChestInteract(BiConsumer<Player, Boolean> h) { this.onChestInteract = h; }
public void setOnHatchInteract(BiConsumer<Player, Boolean> h) { this.onHatchInteract = h; }
public void setOnKillerSneak(BiConsumer<Player, Boolean> h) { this.onKillerSneak = h; }
@EventHandler
public void onSneak(PlayerToggleSneakEvent event) {
Player player = event.getPlayer();
if (!gameManager.isInGame(player.getUniqueId())) return;
if (onKillerSneak != null) {
onKillerSneak.accept(player, event.isSneaking());
}
}
@EventHandler
public void onInteract(PlayerInteractEvent event) {

View file

@ -0,0 +1,55 @@
package dev.loki.listener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.plugin.Plugin;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
/**
* Tracks jump events (velocity Y-positive when on ground) to support the
* carry-escape "spam space" mechanic.
*/
public class PlayerJumpListener implements Listener {
private final Plugin plugin;
private Consumer<Player> onJump;
private final Set<UUID> registered = new HashSet<>();
public PlayerJumpListener(Plugin plugin) {
this.plugin = plugin;
}
public void setOnJump(Consumer<Player> callback) {
this.onJump = callback;
}
public void register() {
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
public void unregister() {
HandlerList.unregisterAll(this);
onJump = null;
registered.clear();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerMove(org.bukkit.event.player.PlayerMoveEvent event) {
// Detect jump: player was on ground and now has positive Y velocity
Player player = event.getPlayer();
if (!player.isOnGround()) return;
// Paper API: check if player jumped (velocity Y > 0 after the tick)
if (player.getVelocity().getY() > 0.1) {
if (onJump != null) {
onJump.accept(player);
}
}
}
}

View file

@ -118,6 +118,12 @@ public class Generator {
return regressionTicks > 0 && !isCompleted && !isBeingRepaired();
}
/** Convenience: set regression state and kick flag together. */
public void setUnderRegression(boolean kicked, int durationTicks) {
this.wasKicked = kicked;
this.regressionTicks = durationTicks;
}
public boolean wasKicked() {
return wasKicked;
}

View file

@ -12,12 +12,10 @@ import java.util.Map;
public class GeneratorManager {
private final Map<Integer, Generator> generators;
private final int requiredCount;
private int completedCount;
public GeneratorManager(GeneratorDataManager dataManager, int requiredCount) {
this.generators = new HashMap<>();
this.requiredCount = requiredCount;
this.completedCount = 0;
loadGenerators(dataManager);
}
@ -28,15 +26,11 @@ public class GeneratorManager {
}
}
public Generator getGenerator(int id) {
return generators.get(id);
}
public Generator getGenerator(int id) { return generators.get(id); }
public Generator getGeneratorAt(Location location) {
for (Generator gen : generators.values()) {
if (gen.isLocationInZone(location)) {
return gen;
}
if (gen.isLocationInZone(location)) return gen;
}
return null;
}
@ -44,14 +38,10 @@ public class GeneratorManager {
public Generator getNearestGenerator(Location location, double maxDistance) {
Generator nearest = null;
double nearestDist = maxDistance;
for (Generator gen : generators.values()) {
if (!gen.getLocation().getWorld().equals(location.getWorld())) continue;
double dist = gen.getDistance(location);
if (dist < nearestDist) {
nearestDist = dist;
nearest = gen;
}
if (dist < nearestDist) { nearestDist = dist; nearest = gen; }
}
return nearest;
}
@ -60,47 +50,21 @@ public class GeneratorManager {
return getGeneratorAt(player.getLocation()) != null;
}
public List<Generator> getAllGenerators() {
return new ArrayList<>(generators.values());
}
public List<Generator> getAllGenerators() { return new ArrayList<>(generators.values()); }
public List<Generator> getIncompleteGenerators() {
List<Generator> incomplete = new ArrayList<>();
for (Generator gen : generators.values()) {
if (!gen.isCompleted()) {
incomplete.add(gen);
}
if (!gen.isCompleted()) incomplete.add(gen);
}
return incomplete;
}
public int getTotalCount() {
return generators.size();
}
public int getRequiredCount() {
return requiredCount;
}
public int getTotalCount() { return generators.size(); }
public int getRequiredCount() { return requiredCount; }
/** Returns actual count of completed generators (recalculated live). */
public int getCompletedCount() {
return completedCount;
}
public int getRemainingRequired() {
return Math.max(0, requiredCount - completedCount);
}
public void onGeneratorCompleted(Generator generator) {
if (generator.isCompleted() && !wasCompletedBefore(generator)) {
completedCount++;
}
}
private boolean wasCompletedBefore(Generator generator) {
return completedCount >= countCompleted();
}
private int countCompleted() {
int count = 0;
for (Generator gen : generators.values()) {
if (gen.isCompleted()) count++;
@ -108,16 +72,23 @@ public class GeneratorManager {
return count;
}
public int getRemainingRequired() {
return Math.max(0, requiredCount - getCompletedCount());
}
/** Called when a generator transitions to completed. */
public void onGeneratorCompleted(Generator generator) {
// Nothing special needed — getCompletedCount() queries live state
}
public boolean areAllRequiredCompleted() {
return completedCount >= requiredCount;
return getCompletedCount() >= requiredCount;
}
public double getAverageProgress() {
if (generators.isEmpty()) return 0;
double total = 0;
for (Generator gen : generators.values()) {
total += gen.getProgress();
}
for (Generator gen : generators.values()) total += gen.getProgress();
return total / generators.size();
}

View file

@ -5,35 +5,24 @@ import dev.loki.config.GameConfig;
public class GeneratorRegression {
private final GameConfig config;
private static final double NORMAL_REGRESSION_PER_TICK = 0.25 / 20.0;
private static final double KICK_REGRESSION_PER_TICK = 2.5 / 20.0;
public GeneratorRegression(GameConfig config) {
this.config = config;
}
public void applyRegression(Generator generator) {
if (generator.isCompleted() || generator.isBeingRepaired()) {
return;
}
if (generator.isCompleted() || generator.isBeingRepaired()) return;
double rate = generator.wasKicked() ? getKickRegressionRate() : getNormalRegressionRate();
generator.removeProgress(rate);
}
public void onGeneratorKicked(Generator generator) {
generator.setKicked(true);
generator.setRegressionTicks(config.getKickDuration() * 20);
generator.setUnderRegression(true, config.getKickDuration() * 20);
}
public void tick(Generator generator) {
if (!generator.isUnderRegression()) {
return;
}
if (!generator.isUnderRegression()) return;
applyRegression(generator);
generator.decrementRegression();
if (generator.getRegressionTicks() <= 0) {
generator.setKicked(false);
}
@ -60,9 +49,4 @@ public class GeneratorRegression {
&& !generator.isBeingRepaired()
&& generator.getProgress() > 0;
}
public static double calculateRegressionLoss(double progress, int regressionTicks, boolean wasKicked) {
double rate = wasKicked ? KICK_REGRESSION_PER_TICK : NORMAL_REGRESSION_PER_TICK;
return Math.min(progress, rate * regressionTicks);
}
}

View file

@ -6,7 +6,10 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.CompassMeta;
import java.util.HashMap;
import java.util.Map;
@ -160,10 +163,39 @@ public class GeneratorRepair {
player.sendActionBar(MiniMessage.miniMessage().deserialize("<green>Отлично! +5%"));
} else {
generator.removeProgress(10.0);
generator.setKicked(true);
generator.setRegressionTicks(600);
generator.setUnderRegression(true, 600);
player.sendMessage(MiniMessage.miniMessage().deserialize("<red>Ошибка! Потеря прогресса!"));
stopRepair(player);
notifyKillerWithCompass(player);
}
}
/** Give every online killer a compass pointing to the failed generator for compass-duration-seconds. */
private void notifyKillerWithCompass(Player failedPlayer) {
int durationTicks = plugin.getConfigManager().getGame().getCompassDurationSeconds() * 20;
RepairSession rs = sessions.get(failedPlayer.getUniqueId());
if (rs == null) return;
Location compassTarget = rs.generator.getLocation();
for (Player online : Bukkit.getOnlinePlayers()) {
if (online.getUniqueId().equals(failedPlayer.getUniqueId())) continue;
online.sendMessage(MiniMessage.miniMessage().deserialize(
"<dark_red>[DBD] ⚡ Выживший допустил ошибку! Компас укажет на генератор!"));
ItemStack compass = new ItemStack(Material.COMPASS);
CompassMeta meta = (CompassMeta) compass.getItemMeta();
if (meta != null) {
meta.setLodestoneTracked(false);
meta.setLodestone(compassTarget);
compass.setItemMeta(meta);
}
ItemStack prev = online.getInventory().getItemInOffHand();
online.getInventory().setItemInOffHand(compass);
plugin.getServer().getScheduler().runTaskLater(plugin, () ->
online.getInventory().setItemInOffHand(prev), durationTicks);
}
}

View file

@ -2,7 +2,6 @@ package dev.loki.phase;
import dev.loki.core.GameSession;
import dev.loki.gate.GateManager;
import dev.loki.gate.GateOpening;
import dev.loki.hatch.Hatch;
import dev.loki.hatch.HatchCondition;
import dev.loki.hatch.HatchEscape;
@ -10,28 +9,35 @@ import dev.loki.player.BasePlayer;
import dev.loki.player.Killer;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import dev.loki.ui.Sidebar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public class EndgamePhase implements Phase {
private final int collapseSeconds;
private int collapseSeconds;
private final int openTicks;
private int remainingTicks;
private boolean complete;
private boolean gatesActivated;
private GateManager gateManager;
private GateOpening gateOpening;
private Hatch hatch;
private HatchCondition hatchCondition;
private HatchEscape hatchEscape;
private Sidebar sidebar;
/** Default constructor — reads collapseSeconds from config on enter() */
public EndgamePhase() {
this.collapseSeconds = 120;
this.collapseSeconds = -1; // resolved in enter()
this.openTicks = 20 * 20;
}
@ -45,68 +51,52 @@ public class EndgamePhase implements Phase {
this.hatchEscape = new HatchEscape(hatch);
}
// -------------------------------------------------------------------------
// Phase lifecycle
// -------------------------------------------------------------------------
@Override
public void enter(GameSession session) {
// Read collapseSeconds from config if not explicitly set
if (collapseSeconds < 0) {
collapseSeconds = session.getConfig().getCollapseSeconds();
}
this.remainingTicks = collapseSeconds * 20;
this.complete = false;
this.gatesActivated = false;
// Pick up GateManager from session if not injected via constructor
if (gateManager == null) {
gateManager = session.getGateManager();
}
// Set up sidebar with endgame timer
sidebar = new Sidebar();
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) sidebar.setupPlayer(p);
}
activateEndgame(session);
announceEndgameStart(session);
}
private void activateEndgame(GameSession session) {
if (gateManager != null && !gatesActivated) {
gateManager.activateEndgame();
gatesActivated = true;
}
checkHatchConditions(session);
session.getPlugin().getLogger().info("Endgame started!");
}
private void checkHatchConditions(GameSession session) {
if (hatch == null || hatchCondition == null) return;
int repairedGens = countRepairedGenerators(session);
hatchCondition.checkAndReveal(hatch, session.getPlayers().values(), repairedGens);
if (isLastSurvivor(session)) {
hatchCondition.checkAndOpen(hatch, session.getPlayers().values(), repairedGens);
if (hatch.isOpen()) {
hatchEscape.notifyNearbyPlayers();
}
}
}
private boolean isLastSurvivor(GameSession session) {
if (hatchCondition == null) return false;
return hatchCondition.isLastSurvivor(session.getPlayers().values());
}
private int countRepairedGenerators(GameSession session) {
return 5;
}
private void announceEndgameStart(GameSession session) {
for (BasePlayer player : session.getPlayers().values()) {
Player bukkitPlayer = player.getPlayer();
if (bukkitPlayer != null) {
bukkitPlayer.sendTitle("§4§lЭНДГЕЙМ!",
"§cКоллапс через 2 минуты!", 10, 70, 20);
bukkitPlayer.playSound(bukkitPlayer.getLocation(),
Sound.ENTITY_WITHER_SPAWN, 1.0f, 0.8f);
}
}
}
@Override
public void tick(GameSession session) {
remainingTicks--;
int secondsLeft = remainingTicks / 20;
// Update sidebar every second
if (remainingTicks % 20 == 0 && sidebar != null) {
sidebar.setEndgameTimer(secondsLeft);
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) {
sidebar.updateGameStats(p, session, session.getGeneratorManager());
}
}
}
updateGates(session);
updateHatch(session);
checkWinConditions(session);
@ -118,79 +108,12 @@ public class EndgamePhase implements Phase {
}
}
private void updateGates(GameSession session) {
if (gateManager == null) return;
gateManager.updateAllGates(1.0 / openTicks);
}
private void updateHatch(GameSession session) {
if (hatch == null || !hatch.isOpen()) return;
for (BasePlayer player : session.getPlayers().values()) {
if (player.isKiller()) continue;
if (player.getState() != PlayerState.HEALTHY &&
player.getState() != PlayerState.INJURED) continue;
if (hatchEscape.canEscape((Survivor) player)) {
hatchEscape.tryEscape((Survivor) player);
}
}
}
private void checkWinConditions(GameSession session) {
boolean allEscapedOrDead = true;
int survivorsAlive = 0;
for (BasePlayer player : session.getPlayers().values()) {
if (player.isKiller()) continue;
PlayerState state = player.getState();
if (state != PlayerState.DEAD && state != PlayerState.ESCAPED) {
allEscapedOrDead = false;
survivorsAlive++;
}
}
if (allEscapedOrDead || survivorsAlive == 0) {
complete = true;
}
}
private void sendCountdown(GameSession session, int secondsLeft) {
if (secondsLeft > 0 && secondsLeft <= 10) {
for (BasePlayer player : session.getPlayers().values()) {
Player bukkitPlayer = player.getPlayer();
if (bukkitPlayer != null) {
bukkitPlayer.sendTitle("§4" + secondsLeft, "", 0, 25, 0);
bukkitPlayer.playSound(bukkitPlayer.getLocation(),
Sound.BLOCK_NOTE_BLOCK_HAT, 0.5f, 1.0f);
}
}
}
}
private void triggerCollapse(GameSession session) {
for (BasePlayer player : session.getPlayers().values()) {
if (player.getState() == PlayerState.DEAD ||
player.getState() == PlayerState.ESCAPED) continue;
Player bukkitPlayer = player.getPlayer();
if (bukkitPlayer == null) continue;
if (!player.isKiller()) {
bukkitPlayer.setHealth(0);
player.setState(PlayerState.DEAD);
} else {
((Killer) player).setCarrying(false);
}
bukkitPlayer.sendTitle("§4§lКОЛЛАПС!",
"§cКарта разрушена!", 10, 70, 20);
}
}
@Override
public void exit(GameSession session) {
if (sidebar != null) {
sidebar.clear();
sidebar = null;
}
announceEndgameEnd(session);
if (hatch != null) {
@ -205,42 +128,6 @@ public class EndgamePhase implements Phase {
}.runTaskLater(session.getPlugin(), 100L);
}
private void announceEndgameEnd(GameSession session) {
List<String> results = new ArrayList<>();
results.add("§6§l=== РЕЗУЛЬТАТЫ ===");
int escaped = 0;
int died = 0;
for (BasePlayer player : session.getPlayers().values()) {
if (player.isKiller()) continue;
PlayerState state = player.getState();
Player bukkitPlayer = player.getPlayer();
String name = bukkitPlayer != null ? bukkitPlayer.getName() : "Unknown";
if (state == PlayerState.ESCAPED) {
results.add("§a✔ " + name + " — Сбежал");
escaped++;
} else {
results.add("§c✘ " + name + " — Погиб");
died++;
}
}
results.add("§7Выживших: §a" + escaped + " §7/ Погибших: §c" + died);
String resultMessage = String.join("\n", results);
for (BasePlayer player : session.getPlayers().values()) {
Player bukkitPlayer = player.getPlayer();
if (bukkitPlayer != null) {
bukkitPlayer.sendMessage(resultMessage);
}
}
}
@Override
public boolean isComplete() {
return complete;
@ -249,4 +136,141 @@ public class EndgamePhase implements Phase {
public int getRemainingSeconds() {
return remainingTicks / 20;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void activateEndgame(GameSession session) {
if (gateManager != null && !gatesActivated) {
gateManager.activateEndgame();
gatesActivated = true;
}
checkHatchConditions(session);
session.getPlugin().getLogger().info("Endgame started!");
}
private void checkHatchConditions(GameSession session) {
if (hatch == null || hatchCondition == null) return;
int repairedGens = countRepairedGenerators(session);
hatchCondition.checkAndReveal(hatch, session.getPlayers().values(), repairedGens);
if (hatchCondition.isLastSurvivor(session.getPlayers().values())) {
hatchCondition.checkAndOpen(hatch, session.getPlayers().values(), repairedGens);
if (hatch.isOpen() && hatchEscape != null) {
hatchEscape.notifyNearbyPlayers();
}
}
}
private int countRepairedGenerators(GameSession session) {
if (session.getGeneratorManager() != null) {
return session.getGeneratorManager().getCompletedCount();
}
return 0;
}
private void announceEndgameStart(GameSession session) {
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null) continue;
Title title = Title.title(
Component.text("ЭНДГЕЙМ!").color(TextColor.color(0xCC0000)),
Component.text("Коллапс через " + collapseSeconds + " сек!").color(NamedTextColor.RED),
Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
);
p.showTitle(title);
p.playSound(p.getLocation(), Sound.ENTITY_WITHER_SPAWN, 1.0f, 0.8f);
}
}
private void updateGates(GameSession session) {
if (gateManager == null) return;
gateManager.updateAllGates(1.0 / openTicks);
}
private void updateHatch(GameSession session) {
if (hatch == null || !hatch.isOpen() || hatchEscape == null) return;
for (BasePlayer player : session.getPlayers().values()) {
if (player.isKiller()) continue;
PlayerState st = player.getState();
if (st != PlayerState.HEALTHY && st != PlayerState.INJURED) continue;
if (hatchEscape.canEscape((Survivor) player)) {
hatchEscape.tryEscape((Survivor) player);
}
}
}
private void checkWinConditions(GameSession session) {
int survivorsAlive = 0;
for (BasePlayer player : session.getPlayers().values()) {
if (player.isKiller()) continue;
PlayerState st = player.getState();
if (st != PlayerState.DEAD && st != PlayerState.ESCAPED) {
survivorsAlive++;
}
}
if (survivorsAlive == 0) {
complete = true;
}
}
private void sendCountdown(GameSession session, int secondsLeft) {
if (secondsLeft > 0 && secondsLeft <= 10) {
for (BasePlayer player : session.getPlayers().values()) {
Player p = player.getPlayer();
if (p == null) continue;
Title t = Title.title(
Component.text(String.valueOf(secondsLeft)).color(TextColor.color(0xFF2222)),
Component.empty(),
Title.Times.times(Duration.ZERO, Duration.ofSeconds(1), Duration.ofMillis(200))
);
p.showTitle(t);
p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_HAT, 0.5f, 1.0f);
}
}
}
private void triggerCollapse(GameSession session) {
for (BasePlayer player : session.getPlayers().values()) {
if (player.getState() == PlayerState.DEAD || player.getState() == PlayerState.ESCAPED) continue;
Player p = player.getPlayer();
if (p == null) continue;
if (!player.isKiller()) {
p.setHealth(0);
player.setState(PlayerState.DEAD);
} else {
((Killer) player).setCarrying(false);
}
Title t = Title.title(
Component.text("КОЛЛАПС!").color(TextColor.color(0xFF0000)),
Component.text("Карта разрушена!").color(NamedTextColor.RED),
Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
);
p.showTitle(t);
}
}
private void announceEndgameEnd(GameSession session) {
List<String> results = new ArrayList<>();
results.add("§6§l=== РЕЗУЛЬТАТЫ ===");
int escaped = 0, died = 0;
for (BasePlayer player : session.getPlayers().values()) {
if (player.isKiller()) continue;
Player p = player.getPlayer();
String name = p != null ? p.getName() : "Unknown";
if (player.getState() == PlayerState.ESCAPED) {
results.add("§a✔ " + name + " — Сбежал");
escaped++;
} else {
results.add("§c✘ " + name + " — Погиб");
died++;
}
}
results.add("§7Выживших: §a" + escaped + " §7/ Погибших: §c" + died);
String msg = String.join("\n", results);
for (BasePlayer player : session.getPlayers().values()) {
Player p = player.getPlayer();
if (p != null) p.sendMessage(msg);
}
}
}

View file

@ -2,6 +2,7 @@ package dev.loki.phase;
import dev.loki.chest.ChestManager;
import dev.loki.chest.ChestOpening;
import dev.loki.config.GameConfig;
import dev.loki.core.GameSession;
import dev.loki.damage.AttackListener;
import dev.loki.damage.DamageHandler;
@ -10,10 +11,13 @@ import dev.loki.gate.GateManager;
import dev.loki.hook.Hook;
import dev.loki.hook.HookManager;
import dev.loki.hook.HookRescue;
import dev.loki.hook.HookSabotage;
import dev.loki.hook.HookSacrifice;
import dev.loki.hook.HookStage;
import dev.loki.killer.GeneratorKick;
import dev.loki.killer.KillerController;
import dev.loki.listener.PlayerInteractListener;
import dev.loki.listener.PlayerJumpListener;
import dev.loki.listener.PlayerMoveListener;
import dev.loki.mdbd;
import dev.loki.objective.GeneratorManager;
@ -29,9 +33,14 @@ import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import dev.loki.state.StateMachine;
import dev.loki.ui.ActionBar;
import dev.loki.ui.BossBarManager;
import dev.loki.ui.Sidebar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
@ -40,6 +49,10 @@ import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import java.time.Duration;
import java.util.Collection;
import java.util.stream.Collectors;
public class GameplayPhase implements Phase {
private final GeneratorManager generatorManager;
@ -55,9 +68,14 @@ public class GameplayPhase implements Phase {
private HookSacrifice hookSacrifice;
private PalletDrop palletDrop;
private PalletBreak palletBreak;
@SuppressWarnings("unused")
private PalletStun palletStun;
private ChestManager chestManager;
private ChestOpening chestOpening;
private GeneratorKick generatorKick;
private HookSabotage hookSabotage;
private BossBarManager bossBarManager;
private Sidebar sidebar;
// Damage system
private PlayerManager playerManager;
@ -68,9 +86,10 @@ public class GameplayPhase implements Phase {
private Listener vanillaDamageBlocker;
private dev.loki.item.ItemUseListener itemUseListener;
// Listener references grabbed from plugin
// Listeners
private PlayerMoveListener moveListener;
private PlayerInteractListener interactListener;
private PlayerJumpListener jumpListener;
public GameplayPhase(GeneratorManager generatorManager) {
this.generatorManager = generatorManager;
@ -83,113 +102,141 @@ public class GameplayPhase implements Phase {
@Override
public void enter(GameSession session) {
mdbd plugin = session.getPlugin();
GameConfig cfg = session.getConfig();
tickCounter = 0;
shouldEnd = false;
allGensDone = false;
// --- Initialise managers ---
// --- Managers ---
palletManager = new PalletManager(plugin.getConfigManager().getLocations());
hookManager = new HookManager(plugin);
hookManager.loadHooks(plugin.getConfigManager().getLocations());
hookManager.setMaxHooks(cfg.getMaxHooks());
gateManager = new GateManager(plugin.getConfigManager().getLocations());
ActionBar ab = new ActionBar();
hookRescue = new HookRescue(plugin, ab);
hookRescue = new HookRescue(plugin, ab, cfg);
hookSacrifice = new HookSacrifice(plugin, null);
palletDrop = new PalletDrop(palletManager);
palletBreak = new PalletBreak(session.getConfig());
palletStun = new PalletStun(session.getConfig());
palletBreak = new PalletBreak(cfg);
palletStun = new PalletStun(cfg);
chestManager = new ChestManager(plugin, plugin.getConfigManager().getLocations());
chestOpening = new ChestOpening(plugin);
// Store managers on session so other code can reach them
// GeneratorKick for the killer
generatorKick = new GeneratorKick(plugin, cfg, generatorManager);
// HookSabotage for survivors
hookSabotage = new HookSabotage(plugin, cfg, hookManager);
session.setPalletManager(palletManager);
session.setHookManager(hookManager);
session.setGateManager(gateManager);
// --- Damage system ---
// Build PlayerManager from current session players
playerManager = new PlayerManager();
for (BasePlayer bp : session.getPlayers().values()) {
playerManager.register(bp);
}
stateMachine = new StateMachine();
stateMachine = new StateMachine();
damageHandler = new DamageHandler(stateMachine);
// When survivor is injured — show title + sound
damageHandler.setOnSurvivorInjured(survivor -> {
Player p = survivor.getPlayer();
if (p != null && p.isOnline()) {
p.showTitle(net.kyori.adventure.title.Title.title(
Component.text("РАНЕН!", net.kyori.adventure.text.format.TextColor.color(0xFF8C00)),
Component.text("Найдите аптечку или союзника", NamedTextColor.YELLOW)
p.showTitle(Title.title(
Component.text("РАНЕН!").color(TextColor.color(0xFF8C00)),
Component.text("Найдите аптечку или союзника").color(NamedTextColor.YELLOW)
));
p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_HURT, 1.0f, 0.8f);
// Keep HP at 20 always — damage is state-based not HP-based
p.setHealth(20.0);
}
});
// When survivor is downed — show title + sound
damageHandler.setOnSurvivorDowned(survivor -> {
Player p = survivor.getPlayer();
if (p != null && p.isOnline()) {
p.showTitle(net.kyori.adventure.title.Title.title(
Component.text("НОКДАУН!", net.kyori.adventure.text.format.TextColor.color(0xFF2222)),
Component.text("Ждите помощи...", NamedTextColor.RED)
p.showTitle(Title.title(
Component.text("НОКДАУН!").color(TextColor.color(0xFF2222)),
Component.text("Ждите помощи...").color(NamedTextColor.RED)
));
p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_HURT, 1.0f, 0.5f);
p.setHealth(20.0);
}
});
killerController = new KillerController(plugin, playerManager, damageHandler, session.getConfig());
// AttackListener — intercepts EntityDamageByEntityEvent, cancels vanilla damage, applies custom
// KillerController — AttackListener зарегистрирован ТОЛЬКО здесь
killerController = new KillerController(plugin, playerManager, damageHandler, cfg);
attackListener = new AttackListener(playerManager, damageHandler,
(killer, survivor) -> killerController.tryAttack(killer, survivor));
attackListener.register(plugin);
// Block ALL vanilla damage for players in this session (fall, fire, mobs, etc.)
// Block vanilla damage
vanillaDamageBlocker = new VanillaDamageBlocker(session);
Bukkit.getPluginManager().registerEvents(vanillaDamageBlocker, plugin);
// KillerController.initialize() does NOT re-register AttackListener
killerController.initialize();
// --- Give starting items to survivors & register item use ---
// --- BossBar ---
Collection<Survivor> survivors = session.getPlayers().values().stream()
.filter(bp -> !bp.isKiller())
.map(bp -> (Survivor) bp)
.collect(Collectors.toList());
Killer killer = (Killer) session.getPlayers().values().stream()
.filter(BasePlayer::isKiller)
.findFirst().orElse(null);
bossBarManager = new BossBarManager(plugin, cfg);
if (killer != null) bossBarManager.setTargets(killer, survivors);
if (cfg.isBossBarEnabled()) bossBarManager.start();
// --- Sidebar ---
sidebar = new Sidebar();
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) sidebar.setupPlayer(p);
}
// --- Jump listener for carry escape ---
jumpListener = new PlayerJumpListener(plugin);
jumpListener.setOnJump(player -> {
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp == null || bp.isKiller()) return;
killerController.getCarry().onSurvivorJump(player.getUniqueId());
});
jumpListener.register();
// --- Items ---
itemUseListener = new dev.loki.item.ItemUseListener(plugin, session);
itemUseListener.register();
giveStartingItems(session);
// Apply player scales via Attribute API
applyPlayerScales(session, cfg);
// --- Wire listeners ---
moveListener = plugin.getMoveListener();
interactListener = plugin.getInteractListener();
wireMoveListener(session);
wireInteractListener(session);
Bukkit.broadcast(Component.text("[DBD] Игра началась! Выжившие, чините генераторы!",
NamedTextColor.GREEN));
NamedTextColor.GREEN));
}
@Override
public void tick(GameSession session) {
tickCounter++;
generatorManager.tick();
// Tick hook timers every game tick
tickHooks(session);
if (tickCounter % 20 == 0) {
checkGeneratorProgress(session);
checkWinConditions(session);
updateSidebar(session);
}
}
@Override
public void exit(GameSession session) {
// Clean up listeners
if (moveListener != null) {
moveListener.setOnSurvivorMove(null);
}
@ -198,41 +245,34 @@ public class GameplayPhase implements Phase {
interactListener.setOnRescueAttempt(null);
interactListener.setOnChestInteract(null);
interactListener.setOnAttack(null);
interactListener.setOnKillerSneak(null);
}
// Stop hooks
if (hookManager != null) {
hookManager.clear();
}
if (hookRescue != null) {
hookRescue.clear();
}
if (chestOpening != null) {
chestOpening.cancelAll();
}
// Unregister damage listeners
if (attackListener != null) {
attackListener.unregister();
if (jumpListener != null) {
jumpListener.unregister();
jumpListener = null;
}
if (hookManager != null) hookManager.clear();
if (hookRescue != null) hookRescue.clear();
if (chestOpening != null) chestOpening.cancelAll();
if (attackListener != null) attackListener.unregister();
if (vanillaDamageBlocker != null) {
HandlerList.unregisterAll(vanillaDamageBlocker);
vanillaDamageBlocker = null;
}
if (killerController != null) {
killerController.shutdown();
}
if (itemUseListener != null) {
itemUseListener.unregister();
}
if (playerManager != null) {
playerManager.clear();
}
// Restore HP for all players
if (killerController != null) killerController.shutdown();
if (generatorKick != null) generatorKick.shutdown();
if (hookSabotage != null) hookSabotage.shutdown();
if (bossBarManager != null) bossBarManager.stop();
if (sidebar != null) { sidebar.clear(); sidebar = null; }
if (itemUseListener != null) itemUseListener.unregister();
if (playerManager != null) playerManager.clear();
// Restore modes and HP
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) {
p.setHealth(20.0);
p.setGameMode(GameMode.ADVENTURE);
}
}
@ -244,6 +284,49 @@ public class GameplayPhase implements Phase {
return shouldEnd;
}
// -------------------------------------------------------------------------
// Attribute-based player scaling
// -------------------------------------------------------------------------
private void applyPlayerScales(GameSession session, GameConfig cfg) {
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null || !p.isOnline()) continue;
double scale = bp.isKiller() ? cfg.getKillerScale() : cfg.getSurvivorScale();
try {
var attr = p.getAttribute(org.bukkit.attribute.Attribute.GENERIC_SCALE);
if (attr != null) attr.setBaseValue(scale);
} catch (Exception ignored) {
// Attribute may not exist on older Paper builds — skip silently
}
}
}
private void resetPlayerScales(GameSession session) {
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null || !p.isOnline()) continue;
try {
var attr = p.getAttribute(org.bukkit.attribute.Attribute.GENERIC_SCALE);
if (attr != null) attr.setBaseValue(1.0);
} catch (Exception ignored) {}
}
}
// -------------------------------------------------------------------------
// Sidebar update
// -------------------------------------------------------------------------
private void updateSidebar(GameSession session) {
if (sidebar == null) return;
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) {
sidebar.updateGameStats(p, session, generatorManager);
}
}
}
// -------------------------------------------------------------------------
// Starting items
// -------------------------------------------------------------------------
@ -254,7 +337,6 @@ public class GameplayPhase implements Phase {
if (bp.isKiller()) continue;
Player p = bp.getPlayer();
if (p == null || !p.isOnline()) continue;
// Clear hotbar slots 0-3, give a random item in slot 0
p.getInventory().clear();
p.getInventory().setItem(0, loot.rollLoot());
p.sendMessage(Component.text("[DBD] Вы получили стартовый предмет!", NamedTextColor.YELLOW));
@ -268,26 +350,45 @@ public class GameplayPhase implements Phase {
private void tickHooks(GameSession session) {
for (Hook hook : hookManager.getAllHooks()) {
if (!hook.isOccupied()) continue;
hook.tick();
Survivor victim = hook.getHookedSurvivor();
if (victim == null) continue;
// Show action bar progress to the hooked survivor
Player vp = victim.getPlayer();
if (vp != null && vp.isOnline()) {
// ActionBar shows stage progress
double pct = hook.getStageProgressPercent();
String label = hook.getCurrentStage() == HookStage.STAGE_2
? "§cStage 2 - struggle!"
: "§eOn hook";
? "§cStage 2 — вырывайтесь!" : "§eНа крюке";
vp.sendActionBar(Component.text(label + " " + (int) pct + "%"));
}
// Sacrifice when timer expires
if (hook.getCurrentStage() == HookStage.SACRIFICE) {
hookSacrifice.sacrificeIfExpired(victim, hook);
// Перевод в SPECTATOR после смерти обрабатывается через setState
}
}
}
// -------------------------------------------------------------------------
// Spectator mode on death / after sacrifice
// -------------------------------------------------------------------------
/**
* Converts a dead survivor to SPECTATOR so they can watch the game.
*/
private void onSurvivorDied(Survivor survivor, GameSession session) {
Player p = survivor.getPlayer();
if (p == null || !p.isOnline()) return;
p.setGameMode(GameMode.SPECTATOR);
p.sendMessage(Component.text("[DBD] Вы мертвы. Наблюдайте за матчем.", NamedTextColor.DARK_RED));
// Let spectator follow a random alive player
for (BasePlayer other : session.getPlayers().values()) {
if (other.getUuid().equals(survivor.getUuid())) continue;
Player op = other.getPlayer();
if (op != null && op.isOnline() && other.getState() != PlayerState.DEAD) {
p.setSpectatorTarget(op);
break;
}
}
}
@ -326,47 +427,51 @@ public class GameplayPhase implements Phase {
PlayerState st = bp.getState();
if (st == PlayerState.ESCAPED) {
escapedSurvivors++;
} else if (st == PlayerState.DEAD) {
// dead, counted below
} else {
// HEALTHY, INJURED, DOWNED, CARRIED, HOOKED — still in game
} else if (st != PlayerState.DEAD) {
aliveSurvivors++;
}
// Переводим мёртвых в SPECTATOR (если ещё не переведены)
if (st == PlayerState.DEAD) {
Player p = bp.getPlayer();
if (p != null && p.isOnline() && p.getGameMode() != GameMode.SPECTATOR) {
onSurvivorDied((Survivor) bp, session);
}
}
}
if (totalSurvivors == 0) return; // no survivors registered yet
if (totalSurvivors == 0) return;
boolean killerWin = (aliveSurvivors == 0 && escapedSurvivors == 0);
boolean survivorsWin = (escapedSurvivors == totalSurvivors);
// Маньяк побеждает — все мертвы и никто не сбежал
boolean killerWin = (aliveSurvivors == 0 && escapedSurvivors == 0);
// Выжившие побеждают — хотя бы один сбежал
boolean survivorsWin = (escapedSurvivors >= 1);
if (killerWin) {
announceKillerWin(session);
shouldEnd = true;
} else if (survivorsWin) {
} else if (survivorsWin && aliveSurvivors == 0) {
// Все либо сбежали либо мертвы — конец матча
announceSurvivorWin(session);
shouldEnd = true;
}
}
private void announceKillerWin(GameSession session) {
Bukkit.broadcast(Component.text("", NamedTextColor.RED));
Bukkit.broadcast(Component.text("☠ КИЛЛЕР ПОБЕДИЛ! ☠", NamedTextColor.DARK_RED));
Bukkit.broadcast(Component.text("Все выжившие уничтожены.", NamedTextColor.RED));
Bukkit.broadcast(Component.text("", NamedTextColor.RED));
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null || !p.isOnline()) continue;
if (bp.isKiller()) {
p.showTitle(net.kyori.adventure.title.Title.title(
Component.text("ПОБЕДА!", net.kyori.adventure.text.format.TextColor.color(0xFF2222)),
Component.text("Ты уничтожил всех выживших", NamedTextColor.RED)
p.showTitle(Title.title(
Component.text("ПОБЕДА!").color(TextColor.color(0xFF2222)),
Component.text("Ты уничтожил всех выживших").color(NamedTextColor.RED)
));
p.playSound(p.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f);
} else {
p.showTitle(net.kyori.adventure.title.Title.title(
Component.text("ПОРАЖЕНИЕ", net.kyori.adventure.text.format.TextColor.color(0x888888)),
Component.text("Киллер победил", NamedTextColor.GRAY)
p.showTitle(Title.title(
Component.text("ПОРАЖЕНИЕ").color(TextColor.color(0x888888)),
Component.text("Киллер победил").color(NamedTextColor.GRAY)
));
p.playSound(p.getLocation(), Sound.ENTITY_WITHER_DEATH, 0.5f, 0.8f);
}
@ -374,24 +479,21 @@ public class GameplayPhase implements Phase {
}
private void announceSurvivorWin(GameSession session) {
Bukkit.broadcast(Component.text("", NamedTextColor.GREEN));
Bukkit.broadcast(Component.text("✔ ВЫЖИВШИЕ ПОБЕДИЛИ! ✔", NamedTextColor.GREEN));
Bukkit.broadcast(Component.text("Все выжившие сбежали!", NamedTextColor.YELLOW));
Bukkit.broadcast(Component.text("", NamedTextColor.GREEN));
Bukkit.broadcast(Component.text("Выжившие сбежали!", NamedTextColor.YELLOW));
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null || !p.isOnline()) continue;
if (!bp.isKiller()) {
p.showTitle(net.kyori.adventure.title.Title.title(
Component.text("ПОБЕГ!", net.kyori.adventure.text.format.TextColor.color(0x00FF00)),
Component.text("Вы выбрались живыми!", NamedTextColor.YELLOW)
p.showTitle(Title.title(
Component.text("ПОБЕГ!").color(TextColor.color(0x00FF00)),
Component.text("Вы выбрались живыми!").color(NamedTextColor.YELLOW)
));
p.playSound(p.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.2f);
} else {
p.showTitle(net.kyori.adventure.title.Title.title(
Component.text("ПОРАЖЕНИЕ", net.kyori.adventure.text.format.TextColor.color(0x888888)),
Component.text("Все выжившие сбежали", NamedTextColor.GRAY)
p.showTitle(Title.title(
Component.text("ПОРАЖЕНИЕ").color(TextColor.color(0x888888)),
Component.text("Выжившие сбежали").color(NamedTextColor.GRAY)
));
p.playSound(p.getLocation(), Sound.ENTITY_WITHER_DEATH, 0.5f, 0.8f);
}
@ -404,12 +506,9 @@ public class GameplayPhase implements Phase {
private void wireMoveListener(GameSession session) {
if (moveListener == null) return;
// Gate escape: survivor walks into open gate zone after all gens done
moveListener.setOnSurvivorMove((survivor, loc) -> {
if (!allGensDone) return;
if (survivor.getState() == PlayerState.ESCAPED) return;
ExitGate gate = gateManager.getGateAt(loc, 3.0);
if (gate != null && gate.isOpen()) {
survivor.setState(PlayerState.ESCAPED);
@ -417,8 +516,7 @@ public class GameplayPhase implements Phase {
if (p != null) {
p.playSound(loc, Sound.ENTITY_PLAYER_LEVELUP, 1.0f, 1.2f);
Bukkit.broadcast(Component.text(
"[DBD] " + p.getName() + " сбежал через ворота!",
NamedTextColor.GOLD));
"[DBD] " + p.getName() + " сбежал через ворота!", NamedTextColor.GOLD));
}
}
});
@ -427,75 +525,78 @@ public class GameplayPhase implements Phase {
private void wireInteractListener(GameSession session) {
if (interactListener == null) return;
// Pallet interact: survivor right-click = drop, killer left-click = break
interactListener.setOnPalletInteract((player, isRightClick) -> {
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp == null) return;
if (isRightClick && !bp.isKiller()) {
palletDrop.tryDrop((Survivor) bp);
} else if (!isRightClick && bp.isKiller()) {
Killer killer = (Killer) bp;
Killer k = (Killer) bp;
Pallet nearby = palletManager.getPalletAt(player.getLocation(), 3.0);
if (nearby != null && nearby.isDropped()) {
palletBreak.startBreaking(killer, nearby, session.getPlugin());
palletBreak.startBreaking(k, nearby, session.getPlugin());
}
}
});
// Rescue attempt: survivor right-clicks near a hook
interactListener.setOnRescueAttempt((player, ignored) -> {
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp == null || bp.isKiller()) return;
Survivor rescuer = (Survivor) bp;
if (rescuer.getState() == PlayerState.HOOKED) return;
Hook hook = hookManager.findNearestOccupiedHook(player.getLocation(), 3.0);
if (hook != null) {
hookRescue.startRescue(rescuer, hook);
}
if (hook != null) hookRescue.startRescue(rescuer, hook);
});
// Killer right-click: hook a carried survivor
interactListener.setOnAttack((player, isRightClick) -> {
if (!isRightClick) return; // left-click handled by damage system
if (!isRightClick) return;
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp == null || !bp.isKiller()) return;
Killer killer = (Killer) bp;
if (killer.getState() == PlayerState.CARRIED || killer.isCarrying()) {
// Find the carried survivor
for (BasePlayer other : session.getPlayers().values()) {
if (!other.isKiller() && other.getState() == PlayerState.CARRIED) {
Survivor carried = (Survivor) other;
if (hookManager.tryHookSurvivor(carried, player.getLocation())) {
killer.setCarrying(false);
// Teleport survivor to hook
Hook nearestHook = hookManager.findNearestOccupiedHook(
player.getLocation(), 5.0);
if (nearestHook != null) {
Player vp = carried.getPlayer();
if (vp != null) {
vp.teleport(nearestHook.getLocation());
}
}
if (!killer.isCarrying()) return;
for (BasePlayer other : session.getPlayers().values()) {
if (!other.isKiller() && other.getState() == PlayerState.CARRIED) {
Survivor carried = (Survivor) other;
if (hookManager.tryHookSurvivor(carried, player.getLocation())) {
killer.setCarrying(false);
Hook nearestHook = hookManager.findNearestOccupiedHook(player.getLocation(), 5.0);
if (nearestHook != null) {
Player vp = carried.getPlayer();
if (vp != null) vp.teleport(nearestHook.getLocation());
}
break;
}
break;
}
}
});
// Sneak: killer → GeneratorKick, survivor → HookSabotage
interactListener.setOnKillerSneak((player, isSneaking) -> {
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp == null) return;
if (bp.isKiller()) {
Killer killer = (Killer) bp;
if (isSneaking) {
generatorKick.startCharging(killer);
} else {
generatorKick.stopCharging(killer.getUuid());
}
} else {
// Survivor sneak → hook sabotage
Survivor surv = (Survivor) bp;
if (isSneaking) {
hookSabotage.startSabotage(surv);
} else {
hookSabotage.stopSabotage(surv.getUuid());
}
}
});
// Chest interact
interactListener.setOnChestInteract((player, ignored) -> {
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp == null || bp.isKiller()) return;
var chest = chestManager.getNearestChest(player.getLocation(), 3.0);
if (chest != null) {
chestOpening.startOpening(player, chest);
}
if (chest != null) chestOpening.startOpening(player, chest);
});
}
@ -505,10 +606,7 @@ public class GameplayPhase implements Phase {
private static class VanillaDamageBlocker implements Listener {
private final GameSession session;
VanillaDamageBlocker(GameSession session) {
this.session = session;
}
VanillaDamageBlocker(GameSession session) { this.session = session; }
@EventHandler(priority = EventPriority.HIGHEST)
public void onDamage(EntityDamageEvent event) {

View file

@ -8,19 +8,25 @@ import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
/**
* Лобби-фаза: ждёт минимум 4 игроков.
* При 4 игроках таймер 45 сек, при 5 — сбрасывается до 15 сек.
*/
public class LobbyPhase implements Phase {
private final GameConfig config;
private int countdown;
private int tickSub; // счётчик тиков внутри текущей секунды
private int secondsLeft;
private boolean started;
private boolean forced;
private int lastPlayerCount; // для отслеживания изменения кол-ва игроков
public LobbyPhase(GameConfig config) {
this.config = config;
this.secondsLeft = config.getLobbyCountdown();
this.countdown = 0;
this.secondsLeft = config.getCountdown4Players(); // начальный таймер
this.tickSub = 0;
this.started = false;
this.forced = false;
this.lastPlayerCount = 0;
}
@Override
@ -43,31 +49,62 @@ public class LobbyPhase implements Phase {
}
int playerCount = session.getPlayerCount();
int minPlayers = config.getMinPlayers();
int minPlayers = config.getMinPlayers(); // 4
int maxPlayers = config.getMaxPlayers(); // 5
// Недостаточно игроков — сбрасываем таймер
if (playerCount < minPlayers) {
secondsLeft = config.getLobbyCountdown();
countdown = 0;
secondsLeft = config.getCountdown4Players();
tickSub = 0;
lastPlayerCount = playerCount;
sendActionBarToAll(session,
Component.text("⏳ Ожидание игроков: " + playerCount + "/" + minPlayers, NamedTextColor.YELLOW));
Component.text("⏳ Ожидание игроков: " + playerCount + "/" + minPlayers,
NamedTextColor.YELLOW));
return;
}
countdown++;
if (countdown >= 20) {
countdown = 0;
secondsLeft--;
// Когда количество игроков меняется — обновляем нужный таймер
if (playerCount != lastPlayerCount) {
lastPlayerCount = playerCount;
if (playerCount >= maxPlayers) {
// При максимуме игроков сокращаем таймер до countdown-5-players
int fast = config.getCountdown5Players();
if (secondsLeft > fast) {
secondsLeft = fast;
tickSub = 0;
Bukkit.broadcast(Component.text(
"[DBD] Все игроки вошли! Старт через " + fast + " сек!",
NamedTextColor.GREEN));
}
}
}
String bar = buildCountdownBar(secondsLeft, config.getLobbyCountdown());
// Тикаем таймер
tickSub++;
if (tickSub >= 20) {
tickSub = 0;
secondsLeft--;
// Анонс вслух на ключевых секундах
if (secondsLeft == 30 || secondsLeft == 15 || secondsLeft == 10
|| secondsLeft == 5 || secondsLeft == 3
|| secondsLeft == 2 || secondsLeft == 1) {
Bukkit.broadcast(Component.text(
"[DBD] До начала: " + secondsLeft + " сек.",
NamedTextColor.YELLOW));
}
}
// ActionBar всем
int total = (playerCount >= maxPlayers)
? config.getCountdown5Players()
: config.getCountdown4Players();
String bar = buildCountdownBar(secondsLeft, total);
NamedTextColor barColor = (playerCount >= maxPlayers)
? NamedTextColor.GREEN : NamedTextColor.YELLOW;
sendActionBarToAll(session,
Component.text("▶ Старт через: " + secondsLeft + "с " + bar +
" (" + playerCount + "/" + minPlayers + ")", NamedTextColor.GREEN));
if (countdown == 0 && (secondsLeft <= 5 || secondsLeft == 10 || secondsLeft == 30)) {
Bukkit.broadcast(Component.text(
"[DBD] До начала: " + secondsLeft + " сек.", NamedTextColor.YELLOW));
}
" (" + playerCount + "/" + minPlayers + ")", barColor));
if (secondsLeft <= 0) {
started = true;
@ -85,7 +122,7 @@ public class LobbyPhase implements Phase {
private String buildCountdownBar(int current, int total) {
int filled = total > 0 ? (int) (10.0 * current / total) : 0;
int empty = 10 - filled;
int empty = 10 - filled;
return "§a" + "█".repeat(Math.max(0, filled)) + "§7" + "░".repeat(Math.max(0, empty));
}
@ -102,9 +139,10 @@ public class LobbyPhase implements Phase {
public void onPlayerJoined(GameSession session, Player player) {
session.getPlugin().getLobbyManager().teleportToLobby(player);
int playerCount = session.getPlayerCount();
int minPlayers = config.getMinPlayers();
int minPlayers = config.getMinPlayers();
Bukkit.broadcast(Component.text(
"[DBD] " + player.getName() + " присоединился к очереди! (" + playerCount + "/" + minPlayers + ")",
"[DBD] " + player.getName() + " присоединился к очереди! (" +
playerCount + "/" + minPlayers + ")",
NamedTextColor.YELLOW));
}

View file

@ -1,5 +1,6 @@
package dev.loki.role;
import dev.loki.config.GameConfig;
import dev.loki.config.LocationsConfig;
import dev.loki.core.GameSession;
import dev.loki.player.BasePlayer;
@ -7,6 +8,7 @@ import dev.loki.player.Killer;
import dev.loki.player.Survivor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import java.util.*;
@ -30,6 +32,7 @@ public class RoleAssigner {
public void assignRoles(GameSession session) {
Map<UUID, BasePlayer> players = session.getPlayers();
GameConfig config = session.getConfig();
if (players.isEmpty()) {
session.getPlugin().getLogger().warning("[RoleAssigner] No players in session!");
return;
@ -42,10 +45,10 @@ public class RoleAssigner {
List<Location> survivorSpawns = locations.getSurvivorSpawns();
if (killerSpawn == null) {
session.getPlugin().getLogger().warning("[RoleAssigner] killer-spawn not set! Using game world spawn.");
session.getPlugin().getLogger().warning("[RoleAssigner] killer-spawn not set!");
}
if (survivorSpawns.isEmpty()) {
session.getPlugin().getLogger().warning("[RoleAssigner] survivor-spawns not set! Using game world spawn.");
session.getPlugin().getLogger().warning("[RoleAssigner] survivor-spawns not set!");
}
int spawnIndex = 0;
@ -69,30 +72,39 @@ public class RoleAssigner {
if (bukkit != null && bukkit.isOnline()) {
if (newPlayer.isKiller()) {
bukkit.showTitle(net.kyori.adventure.title.Title.title(
net.kyori.adventure.text.Component.text("КИЛЛЕР").color(net.kyori.adventure.text.format.TextColor.color(0xFF2222)),
net.kyori.adventure.text.Component.text("Охоться на выживших!").color(net.kyori.adventure.text.format.NamedTextColor.RED)
net.kyori.adventure.text.Component.text("КИЛЛЕР")
.color(net.kyori.adventure.text.format.TextColor.color(0xFF2222)),
net.kyori.adventure.text.Component.text("Охоться на выживших!")
.color(net.kyori.adventure.text.format.NamedTextColor.RED)
));
bukkit.sendMessage(net.kyori.adventure.text.Component.text("[DBD] Ваша роль: Киллер", net.kyori.adventure.text.format.NamedTextColor.RED));
bukkit.sendMessage(net.kyori.adventure.text.Component.text(
"[DBD] Ваша роль: Киллер",
net.kyori.adventure.text.format.NamedTextColor.RED));
applyScale(bukkit, config.getKillerScale());
} else {
bukkit.showTitle(net.kyori.adventure.title.Title.title(
net.kyori.adventure.text.Component.text("ВЫЖИВШИЙ").color(net.kyori.adventure.text.format.TextColor.color(0x00FF00)),
net.kyori.adventure.text.Component.text("Чини генераторы и сбегай!").color(net.kyori.adventure.text.format.NamedTextColor.YELLOW)
net.kyori.adventure.text.Component.text("ВЫЖИВШИЙ")
.color(net.kyori.adventure.text.format.TextColor.color(0x00FF00)),
net.kyori.adventure.text.Component.text("Чини генераторы и сбегай!")
.color(net.kyori.adventure.text.format.NamedTextColor.YELLOW)
));
bukkit.sendMessage(net.kyori.adventure.text.Component.text("[DBD] Ваша роль: Выживший", net.kyori.adventure.text.format.NamedTextColor.GREEN));
bukkit.sendMessage(net.kyori.adventure.text.Component.text(
"[DBD] Ваша роль: Выживший",
net.kyori.adventure.text.format.NamedTextColor.GREEN));
applyScale(bukkit, config.getSurvivorScale());
}
Location spawn = newPlayer.getSpawnLocation();
if (spawn != null) {
bukkit.teleport(spawn);
session.getPlugin().getLogger().info("[RoleAssigner] Teleported " + bukkit.getName() + " to " + spawn);
session.getPlugin().getLogger().info("[RoleAssigner] Teleported " + bukkit.getName());
} else {
var gameWorld = session.getPlugin().getLobbyManager().getGameWorld();
if (gameWorld != null) {
Location fallback = gameWorld.getSpawnLocation();
bukkit.teleport(fallback);
session.getPlugin().getLogger().info("[RoleAssigner] Teleported " + bukkit.getName() + " to game world spawn (no spawn set)");
bukkit.teleport(gameWorld.getSpawnLocation());
} else {
session.getPlugin().getLogger().severe("[RoleAssigner] Game world is null! Cannot teleport " + bukkit.getName());
session.getPlugin().getLogger().severe(
"[RoleAssigner] Game world is null! Cannot teleport " + bukkit.getName());
}
}
}
@ -103,6 +115,14 @@ public class RoleAssigner {
queue.rotate();
}
/** Apply GENERIC_SCALE attribute (Paper 1.20.5+). Silently skips if unavailable. */
private void applyScale(Player player, double scale) {
try {
var attr = player.getAttribute(Attribute.GENERIC_SCALE);
if (attr != null) attr.setBaseValue(scale);
} catch (Exception ignored) {}
}
private UUID selectKiller(List<UUID> uuids) {
UUID queuedKiller = queue.nextKiller();
if (queuedKiller != null && uuids.contains(queuedKiller)) {

View file

@ -12,17 +12,23 @@ import java.util.Collection;
public class TerrorRadiusSound extends BukkitRunnable {
private static final double TERROR_RADIUS = 32.0;
private static final long TICK_INTERVAL = 20L;
private final JavaPlugin plugin;
private final SoundManager soundManager;
private final double terrorRadius;
private Killer killer;
private Collection<Survivor> survivors;
public TerrorRadiusSound(JavaPlugin plugin, SoundManager soundManager) {
public TerrorRadiusSound(JavaPlugin plugin, SoundManager soundManager, double terrorRadius) {
this.plugin = plugin;
this.soundManager = soundManager;
this.terrorRadius = terrorRadius;
}
/** Обратная совместимость — без конфига использует дефолт 32 */
public TerrorRadiusSound(JavaPlugin plugin, SoundManager soundManager) {
this(plugin, soundManager, 32.0);
}
public void setTargets(Killer killer, Collection<Survivor> survivors) {
@ -59,8 +65,8 @@ public class TerrorRadiusSound extends BukkitRunnable {
Location survLoc = survPlayer.getLocation();
double distance = killerLoc.distance(survLoc);
if (distance <= TERROR_RADIUS) {
SoundLevels level = SoundLevels.fromDistance(distance, TERROR_RADIUS);
if (distance <= terrorRadius) {
SoundLevels level = SoundLevels.fromDistance(distance, terrorRadius);
playTerrorSound(survPlayer, level);
}
}
@ -71,14 +77,12 @@ public class TerrorRadiusSound extends BukkitRunnable {
}
public double getTerrorRadius() {
return TERROR_RADIUS;
return terrorRadius;
}
public SoundLevels getTerrorLevel(double distance) {
if (distance > TERROR_RADIUS) {
return null;
}
return SoundLevels.fromDistance(distance, TERROR_RADIUS);
if (distance > terrorRadius) return null;
return SoundLevels.fromDistance(distance, terrorRadius);
}
public void playImmediateTerror(Survivor survivor, SoundLevels level) {

View file

@ -1,5 +1,6 @@
package dev.loki.survivor;
import dev.loki.config.GameConfig;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import org.bukkit.entity.Player;
@ -11,23 +12,17 @@ public class SurvivorController {
private final SurvivorMovement movement;
private final SurvivorHealing healing;
public SurvivorController(Survivor survivor) {
public SurvivorController(Survivor survivor, GameConfig config) {
this.survivor = survivor;
this.movement = new SurvivorMovement(this);
this.healing = new SurvivorHealing(this);
this.healing = new SurvivorHealing(this, config);
}
public void onSprintStart() {
movement.onSprintStart();
}
public void onSprintStart() { movement.onSprintStart(); }
public void onSprintEnd() { movement.onSprintEnd(); }
public void onSprintEnd() {
movement.onSprintEnd();
}
public void onHealAttempt(Player target) {
healing.startHealing(target);
}
public void onHealAttempt(Player target) { healing.startHealing(target); }
public void onHealCancel() { healing.cancelHealing(); }
public void tick() {
movement.tick();
@ -38,40 +33,18 @@ public class SurvivorController {
private void updateSpeed() {
Player player = survivor.getPlayer();
if (player == null) return;
float speed = movement.getCurrentSpeed();
player.setWalkSpeed(speed);
}
public void onStateChange(PlayerState newState) {
movement.onStateChange(newState);
}
public void onStateChange(PlayerState newState) { movement.onStateChange(newState); }
public UUID getUuid() {
return survivor.getUuid();
}
public Survivor getSurvivor() {
return survivor;
}
public Player getPlayer() {
return survivor.getPlayer();
}
public PlayerState getState() {
return survivor.getState();
}
public boolean isDowned() {
return survivor.getState() == PlayerState.DOWNED;
}
public boolean isInjured() {
return survivor.getState() == PlayerState.INJURED;
}
public boolean isHealthy() {
return survivor.getState() == PlayerState.HEALTHY;
}
public UUID getUuid() { return survivor.getUuid(); }
public Survivor getSurvivor() { return survivor; }
public Player getPlayer() { return survivor.getPlayer(); }
public PlayerState getState() { return survivor.getState(); }
public boolean isDowned() { return survivor.getState() == PlayerState.DOWNED; }
public boolean isInjured() { return survivor.getState() == PlayerState.INJURED; }
public boolean isHealthy() { return survivor.getState() == PlayerState.HEALTHY; }
public SurvivorHealing getHealing() { return healing; }
}

View file

@ -1,29 +1,40 @@
package dev.loki.survivor;
import dev.loki.config.GameConfig;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.UUID;
/**
* Handles healing another survivor (shift + proximity, 15 sec by default)
* or self-healing with a medkit (configurable).
* Limits: max-heals-per-match heals per survivor.
*/
public class SurvivorHealing {
private final SurvivorController controller;
private final GameConfig config;
private Player healingTarget;
private int healingTicks;
private int totalHealingTicks;
private boolean isSelfHeal;
private static final int SELF_HEAL_TICKS = 600;
private static final int OTHER_HEAL_TICKS = 320;
private static final double HEAL_RANGE = 3.0;
// Per-survivor heal counter (tracks heals used this match)
private int healsUsed = 0;
public SurvivorHealing(SurvivorController controller) {
public SurvivorHealing(SurvivorController controller, GameConfig config) {
this.controller = controller;
this.config = config;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
public void startHealing(Player target) {
if (isHealing()) return;
if (controller.isDowned()) return;
@ -31,6 +42,12 @@ public class SurvivorHealing {
Player healer = controller.getPlayer();
if (healer == null) return;
// Check heal limit
if (healsUsed >= config.getMaxHealsPerMatch()) {
healer.sendMessage(Component.text("[DBD] Лимит лечений исчерпан!", net.kyori.adventure.text.format.NamedTextColor.RED));
return;
}
UUID targetUuid = target.getUniqueId();
Survivor targetSurvivor = getSurvivorFromPlayer(target);
if (targetSurvivor == null) return;
@ -41,10 +58,18 @@ public class SurvivorHealing {
boolean selfHeal = healer.getUniqueId().equals(targetUuid);
if (selfHeal && targetState == PlayerState.DOWNED) return;
// Range check for other-heal
if (!selfHeal && healer.getLocation().distance(target.getLocation()) > config.getHealRange()) {
healer.sendMessage(Component.text("[DBD] Слишком далеко!", net.kyori.adventure.text.format.NamedTextColor.RED));
return;
}
this.healingTarget = target;
this.isSelfHeal = selfHeal;
this.totalHealingTicks = selfHeal ? SELF_HEAL_TICKS : OTHER_HEAL_TICKS;
this.healingTicks = totalHealingTicks;
this.isSelfHeal = selfHeal;
this.totalHealingTicks = selfHeal
? config.getSelfHealSeconds() * 20
: config.getOtherHealSeconds() * 20;
this.healingTicks = totalHealingTicks;
sendProgressMessage();
}
@ -53,21 +78,11 @@ public class SurvivorHealing {
if (!isHealing()) return;
Player healer = controller.getPlayer();
if (healer == null || healingTarget == null) {
cancelHealing();
return;
}
if (!healingTarget.isOnline()) {
cancelHealing();
return;
}
if (healer == null || healingTarget == null) { cancelHealing(); return; }
if (!healingTarget.isOnline()) { cancelHealing(); return; }
Survivor targetSurvivor = getSurvivorFromPlayer(healingTarget);
if (targetSurvivor == null) {
cancelHealing();
return;
}
if (targetSurvivor == null) { cancelHealing(); return; }
PlayerState targetState = targetSurvivor.getState();
if (targetState != PlayerState.INJURED && targetState != PlayerState.DOWNED) {
@ -75,7 +90,11 @@ public class SurvivorHealing {
return;
}
if (!isSelfHeal && healer.getLocation().distance(healingTarget.getLocation()) > HEAL_RANGE) {
// Must keep sneaking to heal
if (!healer.isSneaking()) { cancelHealing(); return; }
// Range interrupt
if (!isSelfHeal && healer.getLocation().distance(healingTarget.getLocation()) > config.getHealRange()) {
cancelHealing();
return;
}
@ -83,37 +102,53 @@ public class SurvivorHealing {
healingTicks--;
sendProgressMessage();
if (healingTicks <= 0) {
completeHealing(targetSurvivor);
}
}
private void completeHealing(Survivor target) {
PlayerState currentState = target.getState();
PlayerState newState = (currentState == PlayerState.DOWNED) ? PlayerState.INJURED : PlayerState.HEALTHY;
target.setState(newState);
Player targetPlayer = healingTarget;
if (targetPlayer != null && targetPlayer.isOnline()) {
String message = newState == PlayerState.HEALTHY ? "<green>Вы полностью вылечились!"
: "<yellow>Вы встали на ноги!";
targetPlayer.sendMessage(MiniMessage.miniMessage().deserialize(message));
}
cancelHealing();
if (healingTicks <= 0) completeHealing(targetSurvivor);
}
public void cancelHealing() {
if (healingTarget != null && healingTarget.isOnline() && isHealing()) {
healingTarget.sendActionBar(Component.empty());
healingTarget.sendMessage(Component.text("[DBD] Лечение прервано!", net.kyori.adventure.text.format.NamedTextColor.YELLOW));
}
healingTarget = null;
healingTicks = 0;
healingTicks = 0;
}
public boolean isHealing() { return healingTicks > 0; }
public Player getHealingTarget() { return healingTarget; }
public double getProgress() {
if (!isHealing()) return 0;
return 1.0 - ((double) healingTicks / totalHealingTicks);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void completeHealing(Survivor target) {
PlayerState current = target.getState();
PlayerState newState = (current == PlayerState.DOWNED) ? PlayerState.INJURED : PlayerState.HEALTHY;
target.setState(newState);
healsUsed++;
Player targetPlayer = healingTarget;
if (targetPlayer != null && targetPlayer.isOnline()) {
String msg = newState == PlayerState.HEALTHY
? "<green>Вы полностью вылечились!"
: "<yellow>Вы встали на ноги!";
targetPlayer.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
Player healer = controller.getPlayer();
if (healer != null && !healer.getUniqueId().equals(targetPlayer != null ? targetPlayer.getUniqueId() : null)) {
healer.sendMessage(Component.text("[DBD] Союзник вылечен!", net.kyori.adventure.text.format.NamedTextColor.GREEN));
}
cancelHealing();
}
private void sendProgressMessage() {
if (healingTarget == null) return;
double progress = 1.0 - ((double) healingTicks / totalHealingTicks);
String bar = renderProgressBar(progress);
Component message = MiniMessage.miniMessage().deserialize(
@ -121,36 +156,27 @@ public class SurvivorHealing {
: "<gray>Лечение союзника: " + bar
);
healingTarget.sendActionBar(message);
// Also show on healer
Player healer = controller.getPlayer();
if (healer != null && !healer.equals(healingTarget)) {
healer.sendActionBar(message);
}
}
private String renderProgressBar(double progress) {
int filled = (int) (10 * progress);
int empty = 10 - filled;
StringBuilder bar = new StringBuilder("<green>");
bar.append("█".repeat(filled));
bar.append("<gray>");
bar.append("░".repeat(empty));
bar.append(" <white>").append((int)(progress * 100)).append("%");
return bar.toString();
int filled = (int)(10 * progress);
int empty = 10 - filled;
return "<green>" + "█".repeat(filled) + "<gray>" + "░".repeat(empty)
+ " <white>" + (int)(progress * 100) + "%";
}
private Survivor getSurvivorFromPlayer(Player player) {
if (controller.getPlayer() != null && controller.getPlayer().equals(player)) {
return controller.getSurvivor();
}
// For healing other survivors the target won't be this controller's survivor.
// The target state check is done via the Survivor object that must be passed in.
// This method is used for both self and other.
return null;
}
public boolean isHealing() {
return healingTicks > 0;
}
public Player getHealingTarget() {
return healingTarget;
}
public double getProgress() {
if (!isHealing()) return 0;
return 1.0 - ((double) healingTicks / totalHealingTicks);
}
}

View file

@ -0,0 +1,140 @@
package dev.loki.ui;
import dev.loki.config.GameConfig;
import dev.loki.player.Killer;
import dev.loki.player.Survivor;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Displays a BossBar to survivors based on proximity to the Killer.
* The BossBar fills as the Killer gets closer.
*/
public class BossBarManager {
private final Plugin plugin;
private final GameConfig config;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
/** Per-survivor boss bar instance */
private final Map<UUID, BossBar> survivorBossBars = new HashMap<>();
private Killer killer;
private Collection<Survivor> survivors;
private BukkitTask task;
public BossBarManager(Plugin plugin, GameConfig config) {
this.plugin = plugin;
this.config = config;
}
public void setTargets(Killer killer, Collection<Survivor> survivors) {
this.killer = killer;
this.survivors = survivors;
}
public void start() {
task = new BukkitRunnable() {
@Override
public void run() {
tick();
}
}.runTaskTimer(plugin, 0L, 10L); // update every 0.5 sec
}
public void stop() {
if (task != null) {
task.cancel();
task = null;
}
// Hide and remove all boss bars
for (Map.Entry<UUID, BossBar> entry : survivorBossBars.entrySet()) {
Player p = plugin.getServer().getPlayer(entry.getKey());
if (p != null && p.isOnline()) {
p.hideBossBar(entry.getValue());
}
}
survivorBossBars.clear();
}
private void tick() {
if (!config.isBossBarEnabled()) return;
if (killer == null || !killer.isOnline()) return;
Player killerPlayer = killer.getPlayer();
if (killerPlayer == null) return;
int appearRange = config.getBossBarAppearRange();
int fullRange = config.getBossBarFullRange();
for (Survivor survivor : survivors) {
if (!survivor.isOnline()) continue;
if (survivor.getState().isDeadOrEscaped()) continue;
Player survPlayer = survivor.getPlayer();
if (survPlayer == null) continue;
double distance = killerPlayer.getLocation().distance(survPlayer.getLocation());
if (distance > appearRange) {
// Hide bar if previously shown
BossBar bar = survivorBossBars.remove(survivor.getUuid());
if (bar != null) {
survPlayer.hideBossBar(bar);
}
continue;
}
// Calculate fill: 1.0 when at fullRange or closer, 0.0 at appearRange
float progress = (float) Math.min(1.0,
(appearRange - distance) / (double)(appearRange - fullRange));
progress = Math.max(0.01f, progress); // avoid 0 which hides the bar
BossBar bar = survivorBossBars.computeIfAbsent(survivor.getUuid(),
uuid -> createBossBar());
bar.progress(progress);
// Show if not yet visible
survPlayer.showBossBar(bar);
}
}
private BossBar createBossBar() {
Component title = miniMessage.deserialize(config.getBossBarTitle());
BossBar.Color color = parseBossBarColor(config.getBossBarColor());
BossBar.Overlay overlay = parseBossBarOverlay(config.getBossBarStyle());
return BossBar.bossBar(title, 0.01f, color, overlay);
}
private BossBar.Color parseBossBarColor(String name) {
return switch (name.toUpperCase()) {
case "BLUE" -> BossBar.Color.BLUE;
case "GREEN" -> BossBar.Color.GREEN;
case "YELLOW" -> BossBar.Color.YELLOW;
case "PURPLE" -> BossBar.Color.PURPLE;
case "WHITE" -> BossBar.Color.WHITE;
default -> BossBar.Color.RED;
};
}
private BossBar.Overlay parseBossBarOverlay(String name) {
return switch (name.toUpperCase()) {
case "SEGMENTED_6" -> BossBar.Overlay.NOTCHED_6;
case "SEGMENTED_10" -> BossBar.Overlay.NOTCHED_10;
case "SEGMENTED_12" -> BossBar.Overlay.NOTCHED_12;
case "SEGMENTED_20" -> BossBar.Overlay.NOTCHED_20;
default -> BossBar.Overlay.PROGRESS;
};
}
}

View file

@ -1,11 +1,14 @@
package dev.loki.ui;
import dev.loki.config.GameConfig;
import dev.loki.core.GameSession;
import dev.loki.objective.GeneratorManager;
import dev.loki.player.BasePlayer;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.*;
@ -14,13 +17,17 @@ import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Manages the sidebar scoreboard for each player.
* Displays: generator progress, survivor status (with hearts), endgame timer.
*/
public class Sidebar {
private static final String OBJECTIVE_NAME = "dbd_stats";
private final Map<UUID, Scoreboard> scoreboards;
public Sidebar() {
this.scoreboards = new HashMap<>();
}
private final Map<UUID, Scoreboard> scoreboards = new HashMap<>();
// Endgame timer (seconds), -1 = not active
private int endgameSecondsLeft = -1;
public void setupPlayer(Player player) {
ScoreboardManager manager = Bukkit.getScoreboardManager();
@ -38,7 +45,16 @@ public class Sidebar {
scoreboards.put(player.getUniqueId(), board);
}
/** Set/update the endgame countdown. Pass -1 to disable. */
public void setEndgameTimer(int seconds) {
this.endgameSecondsLeft = seconds;
}
public void updateGameStats(Player player, GameSession session) {
updateGameStats(player, session, null);
}
public void updateGameStats(Player player, GameSession session, GeneratorManager generatorManager) {
Scoreboard board = scoreboards.get(player.getUniqueId());
if (board == null) return;
@ -47,44 +63,121 @@ public class Sidebar {
clearScores(board);
GameConfig cfg = session.getConfig();
int line = 15;
setScore(objective, " ", line--);
int completedGens = getCompletedGenerators(session);
int requiredGens = session.getConfig().getGeneratorRequired();
int totalGens = session.getConfig().getGeneratorTotal();
setScore(objective, "§eGenerators:", line--);
setScore(objective, " §f" + completedGens + "/" + requiredGens + " completed", line--);
setScore(objective, " §7(" + totalGens + " total)", line--);
setScore(objective, " ", line--);
long survivorsAlive = countSurvivorsInState(session, PlayerState.HEALTHY, PlayerState.INJURED, PlayerState.DOWNED);
long survivorsHooked = countSurvivorsInState(session, PlayerState.HOOKED);
long survivorsDead = countSurvivorsInState(session, PlayerState.DEAD);
long survivorsEscaped = countSurvivorsInState(session, PlayerState.ESCAPED);
setScore(objective, "§aSurvivors:", line--);
setScore(objective, " §f" + survivorsAlive + " alive", line--);
if (survivorsHooked > 0) {
setScore(objective, " §c" + survivorsHooked + " hooked", line--);
// Endgame timer (if active)
if (endgameSecondsLeft >= 0) {
String timerColor = endgameSecondsLeft <= 30 ? "§c" : "§e";
setScore(objective, "§8─────────────────", line--);
setScore(objective, timerColor + "⏱ До конца: " + endgameSecondsLeft + " сек", line--);
setScore(objective, "§8─────────────────", line--);
}
if (survivorsDead > 0) {
setScore(objective, " §7" + survivorsDead + " dead", line--);
}
if (survivorsEscaped > 0) {
setScore(objective, " §2" + survivorsEscaped + " escaped", line--);
}
setScore(objective, " ", line--);
// Generator progress
int completedGens = generatorManager != null ? generatorManager.getCompletedCount() : 0;
int requiredGens = cfg.getGeneratorRequired();
int totalGens = cfg.getGeneratorTotal();
setScore(objective, "§eГенераторы:", line--);
String genBar = buildGenBar(completedGens, requiredGens);
setScore(objective, " §f" + completedGens + "/" + requiredGens + " " + genBar, line--);
setScore(objective, " §7(всего: " + totalGens + ")", line--);
setScore(objective, " ", line--);
// Survivor list with hearts and colors
setScore(objective, "§aВыжившие:", line--);
for (BasePlayer bp : session.getPlayers().values()) {
if (bp.isKiller()) continue;
Survivor surv = (Survivor) bp;
Player survPlayer = surv.getPlayer();
String name = survPlayer != null ? survPlayer.getName() : "???";
String colorCode = getColorCode(surv.getState(), cfg);
String hearts = buildHearts(surv.getHooksTaken(), cfg.getMaxHooks(), cfg);
String statusIcon = getStatusIcon(surv.getState());
setScore(objective, " " + colorCode + name + " " + hearts + " " + statusIcon, line--);
}
setScore(objective, " ", line--);
// Current player info
BasePlayer bp = session.getPlayer(player.getUniqueId());
if (bp instanceof Survivor surv && !bp.isKiller()) {
if (bp instanceof Survivor surv) {
int hooks = surv.getHooksTaken();
String hookBar = "§f[" + "§c⚓".repeat(hooks) + "§7○".repeat(Math.max(0, 3 - hooks)) + "§f]";
setScore(objective, "Your hooks: " + hookBar, line--);
int maxHooks = cfg.getMaxHooks();
int livesLeft = maxHooks - hooks;
String livesHearts = buildLivesHearts(livesLeft, maxHooks, cfg);
setScore(objective, "§fЖизни: " + livesHearts, line--);
setScore(objective, "§7Статус: " + surv.getState().getDisplayName(), line--);
} else if (bp != null && bp.isKiller()) {
setScore(objective, "§cРоль: §4МАНЬЯК", line--);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private String getColorCode(PlayerState state, GameConfig cfg) {
String colorName = switch (state) {
case INJURED -> cfg.getColorInjured();
case HOOKED -> cfg.getColorHooked();
default -> cfg.getColorHealthy();
};
return switch (colorName.toUpperCase()) {
case "RED" -> "§c";
case "DARK_RED" -> "§4";
case "GREEN" -> "§a";
case "YELLOW" -> "§e";
case "GRAY" -> "§7";
case "DARK_GRAY" -> "§8";
default -> "§f"; // WHITE
};
}
private String buildHearts(int hooksTaken, int maxHooks, GameConfig cfg) {
if (!cfg.isShowHearts()) return "";
String sym = cfg.getHeartSymbol();
int lives = maxHooks - hooksTaken;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lives; i++) sb.append("§c").append(sym);
for (int i = 0; i < hooksTaken; i++) sb.append("§8").append(sym);
return sb.toString();
}
private String buildLivesHearts(int livesLeft, int maxLives, GameConfig cfg) {
String sym = cfg.getHeartSymbol();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < livesLeft; i++) sb.append("§c").append(sym);
for (int i = 0; i < (maxLives - livesLeft); i++) sb.append("§8").append(sym);
return sb.toString();
}
private String getStatusIcon(PlayerState state) {
return switch (state) {
case INJURED -> "§c⚠";
case DOWNED -> "§4✖";
case HOOKED -> "§4⚓";
case CARRIED -> "§5▲";
case DEAD -> "§7✝";
case ESCAPED -> "§a✔";
default -> "";
};
}
private String buildGenBar(int completed, int required) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < required; i++) {
sb.append(i < completed ? "§a■" : "§7□");
}
sb.append("§f]");
return sb.toString();
}
private void setScore(Objective objective, String text, int score) {
objective.getScore(text).setScore(score);
}
@ -95,34 +188,35 @@ public class Sidebar {
}
}
@SuppressWarnings("unused")
private long countSurvivorsInState(GameSession session, PlayerState... states) {
return session.getPlayers().values().stream()
.filter(p -> !p.isKiller())
.filter(p -> {
for (PlayerState state : states) {
if (p.getState() == state) return true;
for (PlayerState s : states) {
if (p.getState() == s) return true;
}
return false;
})
.count();
}
private int getCompletedGenerators(GameSession session) {
return 0;
}
public void removePlayer(Player player) {
Scoreboard board = scoreboards.remove(player.getUniqueId());
if (board != null) {
player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
ScoreboardManager manager = Bukkit.getScoreboardManager();
if (manager != null) {
player.setScoreboard(manager.getMainScoreboard());
}
}
}
public void clear() {
ScoreboardManager manager = Bukkit.getScoreboardManager();
for (Map.Entry<UUID, Scoreboard> entry : scoreboards.entrySet()) {
Player player = Bukkit.getPlayer(entry.getKey());
if (player != null) {
player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
Player p = Bukkit.getPlayer(entry.getKey());
if (p != null && manager != null) {
p.setScoreboard(manager.getMainScoreboard());
}
}
scoreboards.clear();