fix: 10 bug fixes — healing dead players, kick bypass, gate race, swimming ghost, regression config, dead code cleanup, gate zone detection

This commit is contained in:
loki5512344 2026-06-15 10:22:02 +02:00
parent b666f1b0fa
commit d2b174b94c
35 changed files with 587 additions and 773 deletions

View file

@ -15,6 +15,9 @@ public class Chest {
private boolean opened; private boolean opened;
private boolean empty; private boolean empty;
private ItemStack loot; private ItemStack loot;
private long lastEmptiedTime;
private static final long COOLDOWN_MILLIS = 3 * 60 * 1000; // 3 minutes
public Chest(Location location) { public Chest(Location location) {
this.id = UUID.randomUUID(); this.id = UUID.randomUUID();
@ -22,6 +25,7 @@ public class Chest {
this.opened = false; this.opened = false;
this.empty = false; this.empty = false;
this.loot = null; this.loot = null;
this.lastEmptiedTime = 0;
} }
public UUID getId() { public UUID getId() {
@ -52,6 +56,21 @@ public class Chest {
return !opened && !empty && hasLoot(); return !opened && !empty && hasLoot();
} }
public void onLooted() {
this.empty = true;
this.lastEmptiedTime = System.currentTimeMillis();
}
public long getSecondsUntilReset() {
long elapsed = (System.currentTimeMillis() - lastEmptiedTime) / 1000;
long remaining = (COOLDOWN_MILLIS / 1000) - elapsed;
return Math.max(0, remaining);
}
public boolean isOnCooldown() {
return getSecondsUntilReset() > 0;
}
public ItemStack getLoot() { public ItemStack getLoot() {
return loot; return loot;
} }

View file

@ -118,14 +118,8 @@ public class ChestHologram {
String status; String status;
if (chest.isEmpty()) { if (chest.isEmpty()) {
long secs = manager.getSecondsUntilNextReset(); long secs = chest.getSecondsUntilReset();
if (secs > 60) { status = secs > 0 ? "§7Пусто §8(" + secs + "с)" : "§7Пусто";
status = "§7Пуст §8(" + (secs / 60) + "м)";
} else if (secs > 0) {
status = "§7Пуст §8(" + secs + "с)";
} else {
status = "§7Пуст";
}
} else if (chest.isOpened()) { } else if (chest.isOpened()) {
status = "§6Открывается..."; status = "§6Открывается...";
} else if (chest.hasLoot()) { } else if (chest.hasLoot()) {

View file

@ -21,7 +21,6 @@ public class ChestManager {
private final GameConfig gameConfig; private final GameConfig gameConfig;
private ChestLoot chestLoot; private ChestLoot chestLoot;
private BukkitTask lootResetTask; private BukkitTask lootResetTask;
private long lastResetTime = System.currentTimeMillis();
public ChestManager(JavaPlugin plugin, LocationsConfig locationsConfig, GameConfig gameConfig) { public ChestManager(JavaPlugin plugin, LocationsConfig locationsConfig, GameConfig gameConfig) {
this.plugin = plugin; this.plugin = plugin;
@ -36,7 +35,6 @@ public class ChestManager {
List<Location> locations = locationsConfig.getChests(); List<Location> locations = locationsConfig.getChests();
for (Location loc : locations) { for (Location loc : locations) {
Chest chest = new Chest(loc); Chest chest = new Chest(loc);
// Populate loot immediately so it's available
chest.setLoot(chestLoot.rollLoot()); chest.setLoot(chestLoot.rollLoot());
chests.put(chest.getId(), chest); chests.put(chest.getId(), chest);
} }
@ -44,14 +42,12 @@ public class ChestManager {
} }
/** /**
* Start periodic loot reset timer. * Start per-chest cooldown timer. Each chest refills individually 3 min after being looted.
* Every N minutes (from config), all empty chests get new loot.
*/ */
public void startLootResetTimer() { public void startLootResetTimer() {
if (lootResetTask != null) lootResetTask.cancel(); if (lootResetTask != null) lootResetTask.cancel();
long intervalTicks = (long) (gameConfig.getChestLootResetMinutes() * 60 * 20); lootResetTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tickCooldowns, 20L, 20L);
lootResetTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::resetLoot, intervalTicks, intervalTicks); plugin.getLogger().info("Per-chest cooldown timer started (3 min per chest)");
plugin.getLogger().info("Chest loot reset timer started (every " + gameConfig.getChestLootResetMinutes() + " min)");
} }
public void stopLootResetTimer() { public void stopLootResetTimer() {
@ -62,20 +58,16 @@ public class ChestManager {
} }
/** /**
* Reset loot: refill ALL chests with new random loot. * Every second, check each chest — if its cooldown expired, refill it.
*/ */
private void resetLoot() { private void tickCooldowns() {
int refilled = 0;
for (Chest chest : chests.values()) { for (Chest chest : chests.values()) {
// Skip chests currently being opened to avoid race condition
if (chest.isOpened()) continue; if (chest.isOpened()) continue;
chest.setLoot(chestLoot.rollLoot()); if (chest.isEmpty() && !chest.isOnCooldown()) {
chest.setOpened(false); chest.setLoot(chestLoot.rollLoot());
chest.setEmpty(false); chest.setEmpty(false);
refilled++; }
} }
this.lastResetTime = System.currentTimeMillis();
plugin.getLogger().info("[Chest] Loot reset — " + refilled + " chests refilled");
} }
public Chest getChest(UUID id) { public Chest getChest(UUID id) {
@ -117,7 +109,6 @@ public class ChestManager {
chest.setEmpty(false); chest.setEmpty(false);
chest.setLoot(chestLoot.rollLoot()); chest.setLoot(chestLoot.rollLoot());
} }
this.lastResetTime = System.currentTimeMillis();
plugin.getLogger().info("Reset all chests"); plugin.getLogger().info("Reset all chests");
} }
@ -140,13 +131,7 @@ public class ChestManager {
return (int) chests.values().stream().filter(Chest::isAvailable).count(); return (int) chests.values().stream().filter(Chest::isAvailable).count();
} }
/** Seconds until the next automatic loot reset. */
public long getSecondsUntilNextReset() {
long elapsed = (System.currentTimeMillis() - lastResetTime) / 1000;
long total = (long) (gameConfig.getChestLootResetMinutes() * 60);
long remaining = total - elapsed;
return Math.max(0, remaining);
}
public ChestLoot getChestLoot() { public ChestLoot getChestLoot() {
return chestLoot; return chestLoot;

View file

@ -47,15 +47,9 @@ public class ChestOpening {
return false; return false;
} }
// Ensure chest has loot; roll if missing if (!chest.hasLoot() || chest.isEmpty()) {
if (!chest.hasLoot()) { player.sendMessage(messages().get("chest.empty"));
ItemStack rolled = chestLoot.rollLoot(); return false;
if (rolled == null) {
player.sendMessage(messages().get("chest.empty"));
return false;
}
chest.setLoot(rolled);
chest.setEmpty(false);
} }
if (!chest.isAvailable()) { if (!chest.isAvailable()) {
@ -152,7 +146,7 @@ public class ChestOpening {
} }
} }
chest.setOpened(false); chest.setOpened(false);
chest.setEmpty(true); chest.onLooted();
chest.setLoot(null); // Clear loot after giving chest.setLoot(null); // Clear loot after giving
chest.closeVisual(); chest.closeVisual();

View file

@ -3,6 +3,7 @@ package dev.loki.command;
import dev.loki.mdbd; import dev.loki.mdbd;
import dev.loki.util.SpawnUtil; import dev.loki.util.SpawnUtil;
import dev.loki.util.TextUtil; import dev.loki.util.TextUtil;
import dev.loki.config.LocationsConfig;
import dev.loki.config.MessagesConfig; import dev.loki.config.MessagesConfig;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
@ -70,7 +71,8 @@ public class SetupCommand implements TabExecutor {
return switch (args[0].toLowerCase()) { return switch (args[0].toLowerCase()) {
case "wand" -> giveWand(player); case "wand" -> giveWand(player);
case "set" -> handleSet(player, args); case "set" -> handleSet(player, args);
case "clear" -> clearMap(player); case "clear" -> handleClear(player, args);
case "delete" -> handleDelete(player, args);
case "clear-spawns" -> clearSpawns(player); case "clear-spawns" -> clearSpawns(player);
case "lobby" -> setLobby(player); case "lobby" -> setLobby(player);
default -> { sendUsage(sender); yield true; } default -> { sendUsage(sender); yield true; }
@ -331,6 +333,147 @@ public class SetupCommand implements TabExecutor {
return true; return true;
} }
// ── Clear ────────────────────────────────────────────────────────
private boolean handleClear(Player player, String[] args) {
var locations = plugin.getConfigManager().getLocations();
if (args.length >= 2) {
String type = args[1].toLowerCase();
return clearObjectsByType(player, locations, type);
}
// Clear ALL object data from config (no block destruction)
locations.clearLocationList("generators");
locations.clearLocationList("hooks");
locations.clearLocationList("chests");
locations.clearLocationList("killer-spawn");
for (String id : List.of("a", "b")) {
locations.getConfig().set("gate-" + id, null);
locations.getConfig().set("gate-" + id + "-corner1", null);
locations.getConfig().set("gate-" + id + "-corner2", null);
}
locations.clearSurvivorSpawns();
plugin.getGeneratorData().clearGenerators();
plugin.getConfigManager().saveConfig("locations.yml", locations.getConfig());
player.sendMessage(messages().get("setup.cleared"));
return true;
}
private boolean clearObjectsByType(Player player, LocationsConfig locations, String type) {
String path = switch (type) {
case "generator" -> "generators";
case "hook" -> "hooks";
case "chest" -> "chests";
case "gate" -> null;
case "killer-spawn" -> "killer-spawn";
case "survivor-spawn" -> null;
default -> { player.sendMessage(messages().get("setup.unknown-type", type)); yield null; }
};
if (path == null) {
if (type.equals("gate")) {
for (String id : List.of("a", "b")) {
locations.getConfig().set("gate-" + id, null);
locations.getConfig().set("gate-" + id + "-corner1", null);
locations.getConfig().set("gate-" + id + "-corner2", null);
}
} else if (type.equals("survivor-spawn")) {
locations.clearSurvivorSpawns();
} else {
return true;
}
} else {
locations.clearLocationList(path);
if (type.equals("generator")) {
plugin.getGeneratorData().clearGenerators();
}
}
plugin.getConfigManager().saveConfig("locations.yml", locations.getConfig());
player.sendMessage(messages().get("setup.cleared-type", type));
return true;
}
// ── Delete ───────────────────────────────────────────────────────
private boolean handleDelete(Player player, String[] args) {
if (args.length < 3) {
player.sendMessage(messages().get("setup.delete-usage"));
return true;
}
String type = args[1].toLowerCase();
int index;
try {
index = Integer.parseInt(args[2]);
} catch (NumberFormatException e) {
player.sendMessage(messages().get("setup.invalid-number", args[2]));
return true;
}
var locations = plugin.getConfigManager().getLocations();
switch (type) {
case "generator" -> {
var gens = locations.getGenerators();
if (index < 0 || index >= gens.size()) {
player.sendMessage(messages().get("setup.out-of-range", index));
return true;
}
locations.removeLocationAtIndex("generators", index);
plugin.getGeneratorData().removeGenerator(index);
player.sendMessage(messages().get("setup.deleted", "generator #" + index));
}
case "hook" -> {
var hooks = locations.getHooks();
if (index < 0 || index >= hooks.size()) {
player.sendMessage(messages().get("setup.out-of-range", index));
return true;
}
locations.removeLocationAtIndex("hooks", index);
player.sendMessage(messages().get("setup.deleted", "hook #" + index));
}
case "chest" -> {
var chests = locations.getChests();
if (index < 0 || index >= chests.size()) {
player.sendMessage(messages().get("setup.out-of-range", index));
return true;
}
locations.removeLocationAtIndex("chests", index);
player.sendMessage(messages().get("setup.deleted", "chest #" + index));
}
case "killer-spawn" -> {
locations.clearLocationList("killer-spawn");
player.sendMessage(messages().get("setup.deleted", "killer-spawn"));
}
case "survivor-spawn" -> {
if (index < 1 || index > 4) {
player.sendMessage(messages().get("setup.out-of-range", index));
return true;
}
locations.clearSurvivorSpawn(index);
player.sendMessage(messages().get("setup.deleted", "survivor-spawn #" + index));
}
case "gate" -> {
String id = index == 1 ? "a" : index == 2 ? "b" : null;
if (id == null) {
player.sendMessage(messages().get("setup.out-of-range", index));
return true;
}
locations.getConfig().set("gate-" + id, null);
locations.getConfig().set("gate-" + id + "-corner1", null);
locations.getConfig().set("gate-" + id + "-corner2", null);
player.sendMessage(messages().get("setup.deleted", "gate-" + id));
}
default -> {
player.sendMessage(messages().get("setup.unknown-type", type));
return true;
}
}
plugin.getConfigManager().saveConfig("locations.yml", locations.getConfig());
return true;
}
private boolean clearSpawns(Player player) { private boolean clearSpawns(Player player) {
var locations = plugin.getConfigManager().getLocations(); var locations = plugin.getConfigManager().getLocations();
locations.clearLocationList("killer-spawn"); locations.clearLocationList("killer-spawn");
@ -359,6 +502,8 @@ public class SetupCommand implements TabExecutor {
sender.sendMessage(messages().get("usage.setup-killer-spawn")); sender.sendMessage(messages().get("usage.setup-killer-spawn"));
sender.sendMessage(messages().get("usage.setup-survivor-spawn")); sender.sendMessage(messages().get("usage.setup-survivor-spawn"));
sender.sendMessage(messages().get("usage.setup-gate")); sender.sendMessage(messages().get("usage.setup-gate"));
sender.sendMessage(messages().get("usage.setup-clear"));
sender.sendMessage(messages().get("usage.setup-delete"));
sender.sendMessage(messages().get("usage.setup-clear-spawns")); sender.sendMessage(messages().get("usage.setup-clear-spawns"));
sender.sendMessage(messages().get("usage.setup-lobby")); sender.sendMessage(messages().get("usage.setup-lobby"));
} }
@ -373,12 +518,14 @@ public class SetupCommand implements TabExecutor {
@NotNull String label, @NotNull String[] args) { @NotNull String label, @NotNull String[] args) {
if (!sender.hasPermission("mdbd.admin")) return List.of(); if (!sender.hasPermission("mdbd.admin")) return List.of();
if (args.length == 1) { if (args.length == 1) {
return List.of("wand", "set", "clear", "clear-spawns", "lobby").stream() return List.of("wand", "set", "clear", "delete", "clear-spawns", "lobby").stream()
.filter(s -> s.startsWith(args[0].toLowerCase())).toList(); .filter(s -> s.startsWith(args[0].toLowerCase())).toList();
} }
if (args.length == 2 && args[0].equalsIgnoreCase("set")) { if (args.length == 2) {
return List.of("hook", "chest", "generator", "killer-spawn", "survivor-spawn", "gate").stream() if (args[0].equalsIgnoreCase("set") || args[0].equalsIgnoreCase("clear") || args[0].equalsIgnoreCase("delete")) {
.filter(s -> s.startsWith(args[1].toLowerCase())).toList(); return List.of("generator", "hook", "chest", "killer-spawn", "survivor-spawn", "gate").stream()
.filter(s -> s.startsWith(args[1].toLowerCase())).toList();
}
} }
return List.of(); return List.of();
} }

View file

@ -84,8 +84,11 @@ public class GameConfig {
// Endgame // Endgame
setDefault("endgame.gate-open-seconds", 20); setDefault("endgame.gate-open-seconds", 20);
setDefault("endgame.pregame-seconds", 30);
setDefault("endgame.postgame-seconds", 60);
setDefault("endgame.chest-open-seconds", 3); setDefault("endgame.chest-open-seconds", 3);
setDefault("endgame.gate-interaction-range", 3.0); setDefault("endgame.gate-interaction-range", 3.0);
setDefault("endgame.escape-range", 2.0);
// Killer notification // Killer notification
setDefault("killer-notification.compass-duration-seconds", 5); setDefault("killer-notification.compass-duration-seconds", 5);
@ -263,8 +266,11 @@ public class GameConfig {
// Endgame // Endgame
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public int getGateOpenSeconds() { return config.getInt("endgame.gate-open-seconds", 20); } public int getGateOpenSeconds() { return config.getInt("endgame.gate-open-seconds", 20); }
public int getPregameSeconds() { return config.getInt("endgame.pregame-seconds", 30); }
public int getPostgameSeconds() { return config.getInt("endgame.postgame-seconds", 60); }
public int getChestOpenSeconds() { return config.getInt("endgame.chest-open-seconds", 3); } public int getChestOpenSeconds() { return config.getInt("endgame.chest-open-seconds", 3); }
public double getGateInteractionRange() { return config.getDouble("endgame.gate-interaction-range", 3.0); } public double getGateInteractionRange() { return config.getDouble("endgame.gate-interaction-range", 3.0); }
public double getEscapeRange() { return config.getDouble("endgame.escape-range", 2.0); }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Killer notification // Killer notification

View file

@ -130,6 +130,12 @@ public class LocationsConfig {
return -1; return -1;
} }
/** Clear a specific survivor spawn slot (index 1-4). */
public void clearSurvivorSpawn(int index) {
if (index < 1 || index > 4) throw new IllegalArgumentException("Index must be 1-4");
config.set("survivor-spawn-" + index, null);
}
/** Clear all survivor spawn slots. */ /** Clear all survivor spawn slots. */
public void clearSurvivorSpawns() { public void clearSurvivorSpawns() {
for (int i = 1; i <= 4; i++) { for (int i = 1; i <= 4; i++) {
@ -219,6 +225,15 @@ public class LocationsConfig {
public void clearLocationList(String path) { public void clearLocationList(String path) {
config.set(path, new ArrayList<>()); config.set(path, new ArrayList<>());
} }
/** Remove a location at a specific index from a list. */
public void removeLocationAtIndex(String path, int index) {
List<String> list = safeGetStringList(path);
if (index >= 0 && index < list.size()) {
list.remove(index);
config.set(path, list);
}
}
public void setSingleLocation(String path, Location loc) { public void setSingleLocation(String path, Location loc) {
List<String> list = new ArrayList<>(); List<String> list = new ArrayList<>();
list.add(locationToString(loc)); list.add(locationToString(loc));

View file

@ -42,7 +42,8 @@ public class GameSession {
this.ticksElapsed = 0; this.ticksElapsed = 0;
this.roleAssigner = new RoleAssigner(plugin.getConfigManager().getLocations()); this.roleAssigner = new RoleAssigner(plugin.getConfigManager().getLocations());
this.generatorManager = new GeneratorManager( this.generatorManager = new GeneratorManager(
plugin.getGeneratorData(), plugin.getConfigManager().getGame().getGeneratorRequired()); plugin.getGeneratorData(), plugin.getConfigManager().getGame().getGeneratorRequired(),
plugin.getConfigManager().getGame());
} }
public void start() { public void start() {

View file

@ -150,9 +150,7 @@ public class ExitGate {
public void setPowered(boolean powered) { public void setPowered(boolean powered) {
this.powered = powered; this.powered = powered;
if (powered && state == GateState.CLOSED) { // НЕ меняем статус на OPENING — ворота открываются только через взаимодействие игрока
state = GateState.OPENING;
}
} }
public void startOpening() { public void startOpening() {
@ -174,6 +172,16 @@ public class ExitGate {
} }
} }
/** Принудительно открыть ворота (вызывается после таймера эндгейма). */
public void forceOpen() {
this.state = GateState.OPEN;
this.progress = 1.0;
this.savedProgress = 1.0;
removeGateStructure();
spawnOpenParticles();
playOpenedSound();
}
/** Прямая установка прогресса (для линейного расчёта в GateOpening). */ /** Прямая установка прогресса (для линейного расчёта в GateOpening). */
public void setProgressDirect(double value) { public void setProgressDirect(double value) {
if (state != GateState.OPENING) return; if (state != GateState.OPENING) return;
@ -204,6 +212,14 @@ public class ExitGate {
} }
public boolean isInRange(Location playerLoc, double radius) { public boolean isInRange(Location playerLoc, double radius) {
if (hasZone) {
int px = playerLoc.getBlockX(), py = playerLoc.getBlockY(), pz = playerLoc.getBlockZ();
int closestX = Math.max(zoneMinX, Math.min(px, zoneMaxX));
int closestY = Math.max(zoneMinY, Math.min(py, zoneMaxY));
int closestZ = Math.max(zoneMinZ, Math.min(pz, zoneMaxZ));
double dx = px - closestX, dy = py - closestY, dz = pz - closestZ;
return Math.sqrt(dx * dx + dy * dy + dz * dz) <= radius;
}
return location.distance(playerLoc) <= radius; return location.distance(playerLoc) <= radius;
} }

View file

@ -110,6 +110,12 @@ public class GateManager {
return (int) gates.stream().filter(ExitGate::isOpen).count(); return (int) gates.stream().filter(ExitGate::isOpen).count();
} }
public void openAllGates() {
for (ExitGate gate : gates) {
gate.forceOpen();
}
}
public void resetAll() { public void resetAll() {
endgameActive = false; endgameActive = false;
for (ExitGate gate : gates) { for (ExitGate gate : gates) {

View file

@ -38,17 +38,22 @@ public class GateOpening {
ExitGate gate = gateManager.getGateAt(player.getLocation(), INTERACTION_RADIUS); ExitGate gate = gateManager.getGateAt(player.getLocation(), INTERACTION_RADIUS);
if (gate == null) { if (gate == null) {
player.sendMessage(messages.get("chest.door-nearby")); player.sendMessage(messages.get("gate.door-nearby"));
return false; return false;
} }
if (!gate.isPowered()) { if (!gate.isPowered()) {
player.sendMessage(messages.get("chest.door-no-power")); player.sendMessage(messages.get("gate.door-no-power"));
return false; return false;
} }
if (!gate.isClosed() && !gate.isOpening()) { if (gate.isOpening()) {
player.sendMessage(messages.get("chest.door-already")); player.sendMessage(messages.get("gate.door-already"));
return false;
}
if (gate.isOpen()) {
player.sendMessage(messages.get("gate.door-already"));
return false; return false;
} }
@ -61,7 +66,7 @@ public class GateOpening {
if (progress != null) { if (progress != null) {
Player player = survivor.getPlayer(); Player player = survivor.getPlayer();
if (player != null) { if (player != null) {
player.sendMessage(messages.get("chest.door-interrupted")); player.sendMessage(messages.get("gate.door-interrupted"));
} }
} }
} }
@ -74,11 +79,14 @@ public class GateOpening {
Player player = survivor.getPlayer(); Player player = survivor.getPlayer();
if (player == null) return; if (player == null) return;
// Перевести ворота в режим открытия
gate.startOpening();
// Восстановить сохранённый прогресс // Восстановить сохранённый прогресс
gate.restoreProgress(); gate.restoreProgress();
openingPlayers.put(survivor.getUuid(), new OpeningProgress(gate, 0)); openingPlayers.put(survivor.getUuid(), new OpeningProgress(gate, 0));
player.sendMessage(messages.get("chest.door-open")); player.sendMessage(messages.get("gate.door-open"));
new OpeningTask(survivor, gate, plugin).runTaskTimer(plugin, 0L, 1L); new OpeningTask(survivor, gate, plugin).runTaskTimer(plugin, 0L, 1L);
} }
@ -89,7 +97,7 @@ public class GateOpening {
World world = loc.getWorld(); World world = loc.getWorld();
if (player != null) { if (player != null) {
player.sendMessage(messages.get("chest.door-opened")); player.sendMessage(messages.get("gate.door-opened"));
if (world != null) { if (world != null) {
world.playSound(loc, Sound.BLOCK_IRON_DOOR_OPEN, 1.0f, 1.0f); world.playSound(loc, Sound.BLOCK_IRON_DOOR_OPEN, 1.0f, 1.0f);
// Big particle burst when opened // Big particle burst when opened

View file

@ -76,4 +76,6 @@ public class Hook {
public boolean isBasement() { return basement; } public boolean isBasement() { return basement; }
public boolean isDisabled() { return disabled; } public boolean isDisabled() { return disabled; }
public void setDisabled(boolean disabled) { this.disabled = disabled; } public void setDisabled(boolean disabled) { this.disabled = disabled; }
public int getStageProgress() { return stageProgress; }
public void addStageProgress(int delta) { stageProgress = Math.max(0, stageProgress + delta); }
} }

View file

@ -111,10 +111,7 @@ public class HookManager {
return true; // "повешен" = завершён return true; // "повешен" = завершён
} }
Hook nearest = findNearestUnoccupiedHook(killerLoc, 5.0); Hook nearest = findNearestUnoccupiedHook(killerLoc, 4.0);
if (nearest == null) {
nearest = findAvailableHook().orElse(null);
}
if (nearest != null) { if (nearest != null) {
nearest.hookSurvivor(survivor); nearest.hookSurvivor(survivor);
survivor.setState(PlayerState.HOOKED); // Set state to HOOKED survivor.setState(PlayerState.HOOKED); // Set state to HOOKED

View file

@ -39,8 +39,8 @@ public class ItemMedkit implements BaseItem {
meta.setDisplayName("§a" + getType().getDisplayName()); meta.setDisplayName("§a" + getType().getDisplayName());
meta.setLore(Arrays.asList( meta.setLore(Arrays.asList(
"§7" + getType().getDescription(), "§7" + getType().getDescription(),
"§eCharges: §f" + charges + "/" + getType().getDefaultCharges(), "§eЗаряды: §f" + charges + "/" + getType().getDefaultCharges(),
"§7Right-click to heal" "§7ПКМ — лечиться"
)); ));
PersistentDataContainer pdc = meta.getPersistentDataContainer(); PersistentDataContainer pdc = meta.getPersistentDataContainer();
@ -60,7 +60,7 @@ public class ItemMedkit implements BaseItem {
@Override @Override
public void onUse(Player player) { public void onUse(Player player) {
if (charges > 0) { if (charges > 0) {
player.sendMessage("§aYou start healing..."); player.sendMessage(mdbd.getInstance().getConfigManager().getMessages().get("survivor.medkit.start"));
} }
} }

View file

@ -1,8 +1,8 @@
package dev.loki.item; package dev.loki.item;
public enum ItemType { public enum ItemType {
MEDKIT("Medkit", "Heals one health state", 16), MEDKIT("Медкит", "Лечит одно состояние здоровья", 16),
HOOK_CUTTER("Hook Cutter", "Cuts a survivor off a hook", 1); HOOK_CUTTER("Крюкорез", "Срезает выжившего с крюка", 1);
private final String displayName; private final String displayName;
private final String description; private final String description;

View file

@ -14,7 +14,9 @@ import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask; import org.bukkit.scheduler.BukkitTask;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@ -39,6 +41,9 @@ public class GeneratorKick {
private int globalCooldownTicks = 0; private int globalCooldownTicks = 0;
private BukkitTask cooldownTask; private BukkitTask cooldownTask;
/** Pending unblock tasks — cancelled on shutdown to avoid ghost tasks */
private final List<BukkitTask> pendingUnblockTasks = new ArrayList<>();
public GeneratorKick(Plugin plugin, GameConfig config, GeneratorManager generatorManager) { public GeneratorKick(Plugin plugin, GameConfig config, GeneratorManager generatorManager) {
this.plugin = plugin; this.plugin = plugin;
this.config = config; this.config = config;
@ -135,6 +140,8 @@ public class GeneratorKick {
for (BukkitTask t : chargingTasks.values()) t.cancel(); for (BukkitTask t : chargingTasks.values()) t.cancel();
chargingTasks.clear(); chargingTasks.clear();
if (cooldownTask != null) { cooldownTask.cancel(); cooldownTask = null; } if (cooldownTask != null) { cooldownTask.cancel(); cooldownTask = null; }
for (BukkitTask t : pendingUnblockTasks) t.cancel();
pendingUnblockTasks.clear();
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -164,9 +171,10 @@ public class GeneratorKick {
// Unblock generator after kick-duration (separate from global cooldown) // Unblock generator after kick-duration (separate from global cooldown)
int unblockTicks = config.getKickDuration() * 20; int unblockTicks = config.getKickDuration() * 20;
plugin.getServer().getScheduler().runTaskLater(plugin, () -> { BukkitTask unblockTask = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
gen.setBlocked(false); gen.setBlocked(false);
}, unblockTicks); }, unblockTicks);
pendingUnblockTasks.add(unblockTask);
// Start global kick cooldown // Start global kick cooldown
startGlobalCooldown(); startGlobalCooldown();

View file

@ -228,10 +228,32 @@ public class PlayerInteractListener implements Listener {
// #13: Запрет входа в печки для всех (и маньяка тоже) — MUST be FIRST before any other checks // #13: Запрет входа в печки для всех (и маньяка тоже) — MUST be FIRST before any other checks
if (event.getClickedBlock() != null) { if (event.getClickedBlock() != null) {
String type = event.getClickedBlock().getType().toString(); String type = event.getClickedBlock().getType().toString();
if (type.contains("FURNACE") || type.contains("SMOKER") || type.contains("BLAST_FURNACE")) { if (type.contains("FURNACE") || type.contains("SMOKER") || type.contains("BLAST_FURNACE")) {
event.setCancelled(true); event.setCancelled(true);
return; return;
} }
// Chest interaction - cancel vanilla GUI for ALL players (including killer)
if (type.contains("CHEST")) {
event.setCancelled(true);
if (onChestInteract != null && !bp.isKiller()) {
onChestInteract.accept(player, true);
}
return;
}
}
// Gate opening — проверяем ДО генератора, чтобы ворота можно было открыть рядом с геной
if (!bp.isKiller() && onHatchInteract != null) {
var s = gameManager.getPlayerSession(player.getUniqueId());
if (s != null && s.getGateManager() != null) {
var gate = s.getGateManager().getGateAt(player.getLocation(), 3.0);
if (gate != null && !gate.isOpen()) {
onHatchInteract.accept(player, true);
return;
}
}
} }
if (!bp.isKiller() && tryGeneratorInteract(player)) return; if (!bp.isKiller() && tryGeneratorInteract(player)) return;
@ -242,29 +264,15 @@ public class PlayerInteractListener implements Listener {
return; return;
} }
// Gate opening via proximity for survivors — works on air clicks too
if (!bp.isKiller() && onHatchInteract != null) {
onHatchInteract.accept(player, true);
}
if (event.getClickedBlock() == null) return; if (event.getClickedBlock() == null) return;
String type = event.getClickedBlock().getType().toString(); String type = event.getClickedBlock().getType().toString();
// Chest interaction - cancel vanilla GUI and use plugin chest system
if (type.contains("CHEST")) {
event.setCancelled(true);
if (onChestInteract != null && !bp.isKiller()) {
onChestInteract.accept(player, true);
}
return;
}
// Hook rescue: if survivor clicks on a hook block, attempt rescue // Hook rescue: if survivor clicks on a hook block, attempt rescue
// Hook rescue / block protection
if (!bp.isKiller() && onRescueAttempt != null) { if (!bp.isKiller() && onRescueAttempt != null) {
onRescueAttempt.accept(player, true); onRescueAttempt.accept(player, true);
if (type.contains("IRON_BARS") || type.contains("IRON")) event.setCancelled(true);
} }
// Cancel interaction with hook blocks to prevent block manipulation
if (type.contains("IRON_BARS") || type.contains("IRON")) event.setCancelled(true); if (type.contains("IRON_BARS") || type.contains("IRON")) event.setCancelled(true);
} }

View file

@ -1,6 +1,7 @@
package dev.loki.objective; package dev.loki.objective;
import dev.loki.data.GeneratorDataManager; import dev.loki.data.GeneratorDataManager;
import dev.loki.config.GameConfig;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@ -12,11 +13,15 @@ import java.util.Map;
public class GeneratorManager { public class GeneratorManager {
private final Map<Integer, Generator> generators; private final Map<Integer, Generator> generators;
private final int requiredCount; private final int requiredCount;
private final double normalRegressionRate;
private final double kickRegressionRate;
public GeneratorManager(GeneratorDataManager dataManager, int requiredCount) { public GeneratorManager(GeneratorDataManager dataManager, int requiredCount, GameConfig config) {
this.generators = new HashMap<>(); this.generators = new HashMap<>();
this.requiredCount = requiredCount;
loadGenerators(dataManager); loadGenerators(dataManager);
this.requiredCount = Math.min(requiredCount, generators.size());
this.normalRegressionRate = config.getRegressionRate() / 20.0;
this.kickRegressionRate = config.getKickRegressionRate() / 20.0;
} }
private void loadGenerators(GeneratorDataManager dataManager) { private void loadGenerators(GeneratorDataManager dataManager) {
@ -103,7 +108,7 @@ public class GeneratorManager {
public void tick() { public void tick() {
for (Generator gen : generators.values()) { for (Generator gen : generators.values()) {
if (gen.isUnderRegression()) { if (gen.isUnderRegression()) {
double rate = gen.wasKicked() ? 0.125 : 0.0125; double rate = gen.wasKicked() ? kickRegressionRate : normalRegressionRate;
gen.removeProgress(rate); gen.removeProgress(rate);
} }
gen.decrementRegression(); gen.decrementRegression();

View file

@ -1,52 +0,0 @@
package dev.loki.objective;
import dev.loki.config.GameConfig;
public class GeneratorRegression {
private final GameConfig config;
public GeneratorRegression(GameConfig config) {
this.config = config;
}
public void applyRegression(Generator generator) {
if (generator.isCompleted() || generator.isBeingRepaired()) return;
double rate = generator.wasKicked() ? getKickRegressionRate() : getNormalRegressionRate();
generator.removeProgress(rate);
}
public void onGeneratorKicked(Generator generator) {
generator.setUnderRegression(true, config.getKickDuration() * 20);
}
public void tick(Generator generator) {
if (!generator.isUnderRegression()) return;
applyRegression(generator);
generator.decrementRegression();
if (generator.getRegressionTicks() <= 0) {
generator.setKicked(false);
}
}
public void tickAll(GeneratorManager manager) {
for (Generator gen : manager.getAllGenerators()) {
if (!gen.isCompleted() && !gen.isBeingRepaired()) {
tick(gen);
}
}
}
private double getNormalRegressionRate() {
return config.getRegressionRate() / 20.0;
}
private double getKickRegressionRate() {
return config.getKickRegressionRate() / 20.0;
}
public static boolean shouldStartRegression(Generator generator) {
return !generator.isCompleted()
&& !generator.isBeingRepaired()
&& generator.getProgress() > 0;
}
}

View file

@ -2,6 +2,7 @@ package dev.loki.objective;
import dev.loki.mdbd; import dev.loki.mdbd;
import dev.loki.minigames.GeneratorMinigame; import dev.loki.minigames.GeneratorMinigame;
import dev.loki.player.BasePlayer;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
@ -71,8 +72,8 @@ public class GeneratorRepair {
if (session != null) { if (session != null) {
session.generator.setCurrentRepairer(null); session.generator.setCurrentRepairer(null);
// Progress is PRESERVED - generator keeps its progress value // Progress is PRESERVED - generator keeps its progress value
// Only unblock if it was blocked due to an ongoing repair (not a kick) // Only unblock if it was blocked due to an ongoing repair (not a kick or block)
if (!session.generator.isUnderRegression()) { if (!session.generator.isUnderRegression() && !session.generator.isBlocked()) {
session.generator.setBlocked(false); session.generator.setBlocked(false);
} }
player.sendActionBar(Component.empty()); player.sendActionBar(Component.empty());
@ -176,12 +177,18 @@ public class GeneratorRepair {
} }
} }
/** Give every online killer a compass pointing to the failed generator for compass-duration-seconds. */ /** Give only killers a compass pointing to the failed generator for compass-duration-seconds. */
private void notifyKillerWithCompass(Player failedPlayer, Generator generator) { private void notifyKillerWithCompass(Player failedPlayer, Generator generator) {
var session = plugin.getGameManager().getCurrentSession();
if (session == null) return;
int durationTicks = plugin.getConfigManager().getGame().getCompassDurationSeconds() * 20; int durationTicks = plugin.getConfigManager().getGame().getCompassDurationSeconds() * 20;
Location compassTarget = generator.getLocation(); Location compassTarget = generator.getLocation();
for (Player online : Bukkit.getOnlinePlayers()) { for (BasePlayer bp : session.getPlayers().values()) {
if (!bp.isKiller()) continue;
Player online = bp.getPlayer();
if (online == null || !online.isOnline()) continue;
if (online.getUniqueId().equals(failedPlayer.getUniqueId())) continue; if (online.getUniqueId().equals(failedPlayer.getUniqueId())) continue;
online.sendMessage(plugin.getConfigManager().getMessages().get("generator.kicked.notification")); online.sendMessage(plugin.getConfigManager().getMessages().get("generator.kicked.notification"));
@ -196,7 +203,6 @@ public class GeneratorRepair {
ItemStack prev = online.getInventory().getItemInOffHand(); ItemStack prev = online.getInventory().getItemInOffHand();
online.getInventory().setItemInOffHand(compass); online.getInventory().setItemInOffHand(compass);
// Восстанавливаем старый предмет только если в offhand всё ещё компас
plugin.getServer().getScheduler().runTaskLater(plugin, () -> { plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
ItemStack current = online.getInventory().getItemInOffHand(); ItemStack current = online.getInventory().getItemInOffHand();
if (current.getType() == Material.COMPASS) { if (current.getType() == Material.COMPASS) {

View file

@ -1,142 +0,0 @@
package dev.loki.objective;
import dev.loki.mdbd;
import org.bukkit.Bukkit;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class SkillCheck {
private final mdbd plugin;
private final GeneratorRepair generatorRepair;
private final Map<UUID, CheckState> activeChecks;
private static final double ZONE_SIZE = 0.15;
private static final double ROTATION_SPEED = 3.0;
public SkillCheck(mdbd plugin, GeneratorRepair generatorRepair) {
this.plugin = plugin;
this.generatorRepair = generatorRepair;
this.activeChecks = new HashMap<>();
}
public void start(UUID playerId, Generator generator) {
double zoneStart = Math.random() * 0.7;
double zoneEnd = zoneStart + ZONE_SIZE;
CheckState state = new CheckState(
playerId,
generator,
zoneStart,
zoneEnd,
0.0,
ROTATION_SPEED
);
activeChecks.put(playerId, state);
}
public void tick() {
for (CheckState state : new HashMap<>(activeChecks).values()) {
state.update();
if (state.isExpired()) {
onFail(state);
}
}
}
public boolean tryComplete(UUID playerId, boolean isSuccessZone) {
CheckState state = activeChecks.get(playerId);
if (state == null) return false;
if (isSuccessZone) {
onSuccess(state);
} else {
onFail(state);
}
return true;
}
private void onSuccess(CheckState state) {
activeChecks.remove(state.playerId);
Bukkit.getScheduler().runTask(plugin, () ->
generatorRepair.onSkillCheckResult(state.playerId, true, state.generator)
);
}
private void onFail(CheckState state) {
activeChecks.remove(state.playerId);
Bukkit.getScheduler().runTask(plugin, () ->
generatorRepair.onSkillCheckResult(state.playerId, false, state.generator)
);
}
public boolean isActive(UUID playerId) {
return activeChecks.containsKey(playerId);
}
public CheckState getState(UUID playerId) {
return activeChecks.get(playerId);
}
public static class CheckState {
final UUID playerId;
final Generator generator;
final double zoneStart;
final double zoneEnd;
double pointerPosition;
final double rotationSpeed;
int ticks;
CheckState(UUID playerId, Generator generator, double zoneStart, double zoneEnd,
double pointerPosition, double rotationSpeed) {
this.playerId = playerId;
this.generator = generator;
this.zoneStart = zoneStart;
this.zoneEnd = zoneEnd;
this.pointerPosition = pointerPosition;
this.rotationSpeed = rotationSpeed;
this.ticks = 0;
}
void update() {
pointerPosition += rotationSpeed * 0.01;
if (pointerPosition >= 1.0) {
pointerPosition = 0.0;
}
ticks++;
}
boolean isExpired() {
return ticks > 100;
}
boolean isInSuccessZone() {
return pointerPosition >= zoneStart && pointerPosition <= zoneEnd;
}
public String getVisualRepresentation() {
int zoneStartPos = (int) (zoneStart * 20);
int zoneEndPos = (int) (zoneEnd * 20);
int pointerPos = (int) (pointerPosition * 20);
StringBuilder sb = new StringBuilder("<white>[");
for (int i = 0; i < 20; i++) {
if (i == pointerPos) {
sb.append("<red>◆");
} else if (i >= zoneStartPos && i <= zoneEndPos) {
sb.append("<green>█");
} else {
sb.append("<gray>░");
}
}
sb.append("<white>]");
return sb.toString();
}
public double getPointerPosition() {
return pointerPosition;
}
}
}

View file

@ -1,245 +0,0 @@
package dev.loki.objective;
import dev.loki.config.GameConfig;
import dev.loki.mdbd;
import dev.loki.objective.Generator;
import dev.loki.objective.GeneratorRepair;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.*;
/**
* GUI-based Skill Check minigame — ROULETTE style.
*
* Green block moves sequentially left-to-right (0→1→2→...→8→0→...).
* Starts FAST (2-3 ticks), gradually slows down (8-12 ticks).
* ~12-18 switches, then stops on final slot.
* Player must click the GREEN block. Clicking gray = fail. Timeout = fail.
*/
public class SkillCheckInventory {
private final mdbd plugin;
private final Map<UUID, SkillCheckSession> activeSessions = new HashMap<>();
private static final int TIMEOUT_TICKS = 60; // 3 seconds — не выбрал = проигрыш -10%
private static final int MIN_SWITCHES = 5;
private static final int MAX_SWITCHES = 8;
private final Material successMaterial;
private final Material failMaterial;
private final Material neutralMaterial;
public SkillCheckInventory(mdbd plugin) {
this.plugin = plugin;
GameConfig cfg = plugin.getConfigManager().getGame();
this.successMaterial = getMaterial(cfg.getSkillCheckSuccessBlock(), Material.GREEN_STAINED_GLASS_PANE);
this.failMaterial = getMaterial(cfg.getSkillCheckFailBlock(), Material.RED_STAINED_GLASS_PANE);
this.neutralMaterial = getMaterial(cfg.getSkillCheckNeutralBlock(), Material.GRAY_STAINED_GLASS_PANE);
}
private static Material getMaterial(String name, Material fallback) {
Material m = Material.getMaterial(name);
return m != null ? m : fallback;
}
public void startSkillCheck(Player player, UUID playerId, SkillCheck skillCheck, Generator generator, GeneratorRepair generatorRepair) {
SkillCheckSession session = new SkillCheckSession(player, playerId, skillCheck, generator, generatorRepair);
activeSessions.put(playerId, session);
Inventory inv = Bukkit.createInventory(null, 9, Component.text("⚡ Skill Check!").color(NamedTextColor.YELLOW));
fillInventory(inv, session.successSlot);
player.openInventory(inv);
// Schedule timeout
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (activeSessions.containsKey(playerId)) {
onTimeout(playerId);
}
}, TIMEOUT_TICKS);
// Start roulette animation
runRoulette(playerId);
}
/**
* Roulette animation: green block moves left-to-right sequentially,
* slowing down over time like a real roulette wheel.
*/
private void runRoulette(UUID playerId) {
SkillCheckSession session = activeSessions.get(playerId);
if (session == null || !session.player.isOnline()) {
activeSessions.remove(playerId);
return;
}
// Check if timeout happened
if (session.totalTicks >= TIMEOUT_TICKS) {
onTimeout(playerId);
return;
}
// Move green sequentially to next slot
session.lastSuccessSlot = session.successSlot;
session.successSlot = (session.successSlot + 1) % 9;
session.switchesDone++;
// Update inventory visually
Inventory inv = session.player.getOpenInventory().getTopInventory();
if (inv != null && inv.getSize() == 9) {
inv.setItem(session.lastSuccessSlot, createNeutralGlass());
inv.setItem(session.successSlot, createSuccessGlass());
// Play tick sound for roulette feel
session.player.playSound(session.player.getLocation(), Sound.BLOCK_NOTE_BLOCK_HAT, 0.5f, 1.5f);
}
// Calculate next delay — roulette slows down
int nextDelay;
int done = session.switchesDone;
int total = session.totalSwitches;
if (done < total * 0.4) {
// Fast phase: 40% of switches
nextDelay = 2 + (int)(Math.random() * 2); // 2-3 ticks
} else if (done < total * 0.8) {
// Medium phase: 40% of switches
nextDelay = 4 + (int)(Math.random() * 3); // 4-6 ticks
} else {
// Slow phase: final 20% of switches
nextDelay = 8 + (int)(Math.random() * 5); // 8-12 ticks
}
session.totalTicks += nextDelay;
// Stop roulette after totalSwitches — green stays on final slot
if (done >= total) {
// Roulette stopped — player must click now (timeout still ticking)
return;
}
// Continue roulette
plugin.getServer().getScheduler().runTaskLater(plugin, () -> runRoulette(playerId), nextDelay);
}
/**
* Called when the skill check inventory is closed (e.g. player presses ESC).
* Auto-fail: treats close as timeout.
*/
public void onInventoryClose(InventoryCloseEvent event) {
if (!(event.getPlayer() instanceof Player player)) return;
UUID playerId = player.getUniqueId();
if (activeSessions.containsKey(playerId)) {
onTimeout(playerId);
}
}
public void handleClick(InventoryClickEvent event, UUID playerId) {
// event.setCancelled(true) is already called in SkillCheckListener before this
SkillCheckSession session = activeSessions.remove(playerId);
if (session == null) return;
Player player = event.getWhoClicked() instanceof Player ? (Player) event.getWhoClicked() : null;
if (player == null) return;
player.closeInventory();
int clickedSlot = event.getRawSlot();
boolean success = (clickedSlot == session.successSlot);
if (success) {
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1.0f, 1.5f);
} else {
player.playSound(player.getLocation(), Sound.ENTITY_BLAZE_HURT, 1.0f, 0.5f);
}
session.generatorRepair.onSkillCheckResult(playerId, success, session.generator);
}
private void onTimeout(UUID playerId) {
SkillCheckSession session = activeSessions.remove(playerId);
if (session == null) return;
Player player = session.player;
if (player != null && player.isOnline()) {
player.closeInventory();
player.playSound(player.getLocation(), Sound.ENTITY_BLAZE_HURT, 1.0f, 0.5f);
}
// Timeout = failure
session.generatorRepair.onSkillCheckResult(playerId, false, session.generator);
}
private void fillInventory(Inventory inv, int greenSlot) {
for (int i = 0; i < 9; i++) {
inv.setItem(i, i == greenSlot ? createSuccessGlass() : createNeutralGlass());
}
}
private ItemStack createSuccessGlass() {
ItemStack glass = new ItemStack(successMaterial);
ItemMeta meta = glass.getItemMeta();
if (meta != null) {
meta.displayName(Component.text("✓ Нажми!").color(NamedTextColor.GREEN));
glass.setItemMeta(meta);
}
return glass;
}
private ItemStack createFailGlass() {
ItemStack glass = new ItemStack(failMaterial);
ItemMeta meta = glass.getItemMeta();
if (meta != null) {
meta.displayName(Component.text("✗").color(NamedTextColor.RED));
glass.setItemMeta(meta);
}
return glass;
}
private ItemStack createNeutralGlass() {
ItemStack glass = new ItemStack(neutralMaterial);
ItemMeta meta = glass.getItemMeta();
if (meta != null) {
meta.displayName(Component.text("-").color(NamedTextColor.GRAY));
glass.setItemMeta(meta);
}
return glass;
}
public void clear() {
for (UUID uuid : new HashSet<>(activeSessions.keySet())) {
SkillCheckSession session = activeSessions.remove(uuid);
if (session.player != null && session.player.isOnline()) {
session.player.closeInventory();
}
}
activeSessions.clear();
}
private static class SkillCheckSession {
final Player player;
final UUID playerId;
final SkillCheck skillCheck;
final Generator generator;
final GeneratorRepair generatorRepair;
int successSlot = 0;
int lastSuccessSlot = -1;
int switchesDone = 0;
final int totalSwitches = MIN_SWITCHES + new Random().nextInt(MAX_SWITCHES - MIN_SWITCHES + 1);
int totalTicks = 0;
SkillCheckSession(Player player, UUID playerId, SkillCheck skillCheck, Generator generator, GeneratorRepair generatorRepair) {
this.player = player;
this.playerId = playerId;
this.skillCheck = skillCheck;
this.generator = generator;
this.generatorRepair = generatorRepair;
}
}
}

View file

@ -1,120 +0,0 @@
package dev.loki.objective;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.title.Title;
import org.bukkit.entity.Player;
import java.util.UUID;
public class SkillCheckUI {
private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();
public static void showSkillCheck(Player player, SkillCheck.CheckState state) {
String visual = buildVisualDisplay(state);
Component title = MINI_MESSAGE.deserialize(visual);
Component subtitle = MINI_MESSAGE.deserialize("<gray>Нажмите <yellow>ПКМ</yellow> в зеленой зоне!");
player.showTitle(Title.title(title, subtitle));
}
public static void showActionBarSkillCheck(Player player, SkillCheck.CheckState state) {
String visual = buildCompactVisual(state);
player.sendActionBar(MiniMessage.miniMessage().deserialize(visual));
}
public static void showSuccess(Player player) {
player.showTitle(Title.title(
MINI_MESSAGE.deserialize("<green>✓ Отлично!"),
Component.empty(),
Title.Times.times(java.time.Duration.ZERO, java.time.Duration.ofMillis(500), java.time.Duration.ZERO)
));
}
public static void showFail(Player player) {
player.showTitle(Title.title(
MINI_MESSAGE.deserialize("<red>✗ Провал!"),
MINI_MESSAGE.deserialize("<gray>Потеря прогресса..."),
Title.Times.times(java.time.Duration.ZERO, java.time.Duration.ofMillis(800), java.time.Duration.ZERO)
));
}
public static void clear(Player player) {
player.clearTitle();
player.sendActionBar(Component.empty());
}
private static String buildVisualDisplay(SkillCheck.CheckState state) {
double zoneStart = state.zoneStart;
double zoneEnd = state.zoneEnd;
double pointer = state.getPointerPosition();
int displayWidth = 15;
int zoneStartPos = (int) (zoneStart * displayWidth);
int zoneEndPos = (int) (zoneEnd * displayWidth);
int pointerPos = (int) (pointer * displayWidth);
StringBuilder sb = new StringBuilder();
sb.append("<gray>");
for (int i = 0; i < displayWidth; i++) {
if (i == pointerPos) {
sb.append("<white>◆");
} else if (i >= zoneStartPos && i <= zoneEndPos) {
sb.append("<green>█");
} else {
sb.append("<dark_gray>▒");
}
}
sb.append("<gray>");
return sb.toString();
}
private static String buildCompactVisual(SkillCheck.CheckState state) {
double zoneStart = state.zoneStart;
double zoneEnd = state.zoneEnd;
double pointer = state.getPointerPosition();
int displayWidth = 10;
int zoneStartPos = (int) (zoneStart * displayWidth);
int zoneEndPos = (int) (zoneEnd * displayWidth);
int pointerPos = (int) (pointer * displayWidth);
StringBuilder sb = new StringBuilder();
sb.append("<yellow>QTE: <white>[");
for (int i = 0; i < displayWidth; i++) {
if (i == pointerPos) {
sb.append("<red>♦");
} else if (i >= zoneStartPos && i <= zoneEndPos) {
sb.append("<green>█");
} else {
sb.append("<gray>░");
}
}
sb.append("<white>]");
return sb.toString();
}
public static String getSkillCheckText(UUID playerId, double pointer, double zoneStart, double zoneEnd) {
int width = 12;
int pPos = (int) (pointer * width);
int zStart = (int) (zoneStart * width);
int zEnd = (int) (zoneEnd * width);
StringBuilder sb = new StringBuilder("<gray>[");
for (int i = 0; i < width; i++) {
if (i == pPos) {
sb.append("<white>◆");
} else if (i >= zStart && i <= zEnd) {
sb.append("<green>█");
} else {
sb.append("<dark_gray>░");
}
}
sb.append("<gray>]");
return sb.toString();
}
}

View file

@ -78,6 +78,15 @@ public class EndgamePhase implements Phase {
gateManager = session.getGateManager(); gateManager = session.getGateManager();
} }
// Если ворота уже открыты — игра завершилась по таймеру эндгейма,
// показываем результаты сразу
if (gateManager != null && gateManager.getOpenGateCount() > 0) {
this.complete = true;
announceEndgameEnd(session);
teleportAllToLobby(session);
return;
}
// Set up sidebar // Set up sidebar
sidebar = new Sidebar(); sidebar = new Sidebar();
for (BasePlayer bp : session.getPlayers().values()) { for (BasePlayer bp : session.getPlayers().values()) {
@ -101,7 +110,6 @@ public class EndgamePhase implements Phase {
} }
} }
updateGates(session);
updateHatch(session); updateHatch(session);
checkWinConditions(session); checkWinConditions(session);
} }
@ -123,6 +131,10 @@ public class EndgamePhase implements Phase {
hatch.reset(); hatch.reset();
} }
if (gateManager != null) {
gateManager.resetAll();
}
// exit() НЕ вызывает session.stop() — это делает transitionToNextPhase() // exit() НЕ вызывает session.stop() — это делает transitionToNextPhase()
// Телепорт и результаты уже обработаны в enter() или будут в teleportAllToLobby // Телепорт и результаты уже обработаны в enter() или будут в teleportAllToLobby
} }

View file

@ -42,6 +42,7 @@ public class GameplayDamageSystem {
private Listener vanillaDamageBlocker; private Listener vanillaDamageBlocker;
private ItemUseListener itemUseListener; private ItemUseListener itemUseListener;
private PlayerJumpListener jumpListener; private PlayerJumpListener jumpListener;
private org.bukkit.scheduler.BukkitTask swimmingTask;
/** /**
* Initialize damage and combat system. * Initialize damage and combat system.
@ -83,18 +84,21 @@ public class GameplayDamageSystem {
p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_HURT, 1.0f, 0.5f); p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_HURT, 1.0f, 0.5f);
p.setHealth(20.0); p.setHealth(20.0);
p.setSwimming(true); p.setSwimming(true);
Bukkit.getScheduler().runTaskTimer(plugin, task -> { swimmingTask = new org.bukkit.scheduler.BukkitRunnable() {
if (!p.isOnline()) { @Override
task.cancel(); public void run() {
return; if (!p.isOnline()) {
cancel();
return;
}
if (survivor.getState() != PlayerState.DOWNED) {
p.setSwimming(false);
cancel();
return;
}
if (!p.isSwimming()) p.setSwimming(true);
} }
if (survivor.getState() != PlayerState.DOWNED) { }.runTaskTimer(plugin, 1L, 5L);
p.setSwimming(false);
task.cancel();
return;
}
if (!p.isSwimming()) p.setSwimming(true);
}, 1L, 5L);
}); });
killerController = new KillerController(plugin, playerManager, damageHandler, cfg); killerController = new KillerController(plugin, playerManager, damageHandler, cfg);
@ -138,6 +142,10 @@ public class GameplayDamageSystem {
* Clear all damage system resources. * Clear all damage system resources.
*/ */
public void clear() { public void clear() {
if (swimmingTask != null) {
swimmingTask.cancel();
swimmingTask = null;
}
if (jumpListener != null) { if (jumpListener != null) {
jumpListener.clearInGamePlayers(); jumpListener.clearInGamePlayers();
jumpListener.unregister(); jumpListener.unregister();
@ -156,7 +164,15 @@ public class GameplayDamageSystem {
*/ */
public void clearManagers() { public void clearManagers() {
if (killerController != null) killerController.shutdown(); if (killerController != null) killerController.shutdown();
if (playerManager != null) playerManager.clear(); if (playerManager != null) {
for (BasePlayer bp : playerManager.getAllPlayers()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) {
p.setSwimming(false);
}
}
playerManager.clear();
}
} }
// ========================================================================= // =========================================================================

View file

@ -4,8 +4,10 @@ import dev.loki.config.GameConfig;
import dev.loki.core.GameSession; import dev.loki.core.GameSession;
import dev.loki.hook.*; import dev.loki.hook.*;
import dev.loki.player.BasePlayer; import dev.loki.player.BasePlayer;
import dev.loki.player.PlayerState;
import dev.loki.player.Survivor; import dev.loki.player.Survivor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
/** /**
@ -18,6 +20,10 @@ public class GameplayHookHandler {
private final HookSacrifice hookSacrifice; private final HookSacrifice hookSacrifice;
private final HookStruggle hookStruggle; private final HookStruggle hookStruggle;
private final HookRecovery hookRecovery; private final HookRecovery hookRecovery;
private GameSession session;
private static final double CAMP_REGRESS_RANGE = 7.0;
private static final double CAMP_STALL_RANGE = 13.0;
public GameplayHookHandler(HookManager hookManager, HookSacrifice hookSacrifice, public GameplayHookHandler(HookManager hookManager, HookSacrifice hookSacrifice,
HookStruggle hookStruggle, HookRecovery hookRecovery) { HookStruggle hookStruggle, HookRecovery hookRecovery) {
@ -27,12 +33,17 @@ public class GameplayHookHandler {
this.hookRecovery = hookRecovery; this.hookRecovery = hookRecovery;
} }
public void setSession(GameSession session) {
this.session = session;
}
private int hookTpTick; private int hookTpTick;
/** /**
* Called every tick - checks hook stage progression and teleports hooked players back. * Called every tick - checks hook stage progression, killer proximity, and teleports hooked players back.
*/ */
public void tick() { public void tick() {
applyKillerProximity();
checkHookStages(); checkHookStages();
hookTpTick++; hookTpTick++;
if (hookTpTick >= 20) { if (hookTpTick >= 20) {
@ -41,6 +52,45 @@ public class GameplayHookHandler {
} }
} }
/**
* If the killer is near a hook, slow or regress the hook progression.
* < 7 blocks → regress (goes negative)
* 7-13 blocks → stall (no progression)
* > 13 blocks → normal
*/
private void applyKillerProximity() {
if (session == null) return;
Location killerLoc = null;
for (BasePlayer bp : session.getPlayers().values()) {
if (bp.isKiller()) {
Player p = bp.getPlayer();
if (p != null && p.isOnline()) {
killerLoc = p.getLocation();
}
break;
}
}
if (killerLoc == null) return;
for (Hook hook : hookManager.getAllHooks()) {
if (!hook.isOccupied()) continue;
if (!killerLoc.getWorld().equals(hook.getLocation().getWorld())) continue;
double dist = killerLoc.distance(hook.getLocation());
if (dist < CAMP_REGRESS_RANGE) {
// Regress: -2 per tick (faster than normal +1)
hook.addStageProgress(-2);
// If STAGE_1 and progress goes to 0, that's as low as it gets
} else if (dist < CAMP_STALL_RANGE) {
// Stall: cancel the normal +1 by adding -1
hook.addStageProgress(-1);
}
// else: normal progression (Hook.tick already adds +1)
}
}
/** /**
* Teleport hooked survivors back to their hook if they've moved too far. * Teleport hooked survivors back to their hook if they've moved too far.
*/ */

View file

@ -110,21 +110,7 @@ public class GameplayInteraction {
} }
private void wireMoveListener(GameSession session) { private void wireMoveListener(GameSession session) {
if (moveListener == null) return; // Escape check is handled in GameplayPhase.checkEscapes() every tick during POSTGAME
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);
Player p = survivor.getPlayer();
if (p != null) {
p.playSound(loc, Sound.ENTITY_PLAYER_LEVELUP, 1.0f, 1.2f);
Bukkit.broadcast(Component.text(
"[DBD] " + p.getName() + " сбежал через ворота!", NamedTextColor.GOLD));
}
}
});
} }
private void wireInteractListener(GameSession session) { private void wireInteractListener(GameSession session) {
@ -156,10 +142,13 @@ public class GameplayInteraction {
Survivor carried = (Survivor) other; Survivor carried = (Survivor) other;
if (hookManager.tryHookSurvivor(carried, player.getLocation())) { if (hookManager.tryHookSurvivor(carried, player.getLocation())) {
killer.setCarrying(false); killer.setCarrying(false);
Hook nearest = hookManager.findNearestOccupiedHook(player.getLocation(), 5.0); // Only teleport if actually hooked (not dead from exceeding hook limit)
if (nearest != null) { if (carried.getState() == PlayerState.HOOKED) {
Player vp = carried.getPlayer(); Hook nearest = hookManager.findNearestOccupiedHook(player.getLocation(), 5.0);
if (vp != null) vp.teleport(nearest.getLocation()); if (nearest != null) {
Player vp = carried.getPlayer();
if (vp != null) vp.teleport(nearest.getLocation());
}
} }
} }
break; break;
@ -190,10 +179,12 @@ public class GameplayInteraction {
Survivor carried = (Survivor) other; Survivor carried = (Survivor) other;
if (hookManager.tryHookSurvivor(carried, player.getLocation())) { if (hookManager.tryHookSurvivor(carried, player.getLocation())) {
killer.setCarrying(false); killer.setCarrying(false);
Hook nearest = hookManager.findNearestOccupiedHook(player.getLocation(), 5.0); if (carried.getState() == PlayerState.HOOKED) {
if (nearest != null) { Hook nearest = hookManager.findNearestOccupiedHook(player.getLocation(), 5.0);
Player vp = carried.getPlayer(); if (nearest != null) {
if (vp != null) vp.teleport(nearest.getLocation()); Player vp = carried.getPlayer();
if (vp != null) vp.teleport(nearest.getLocation());
}
} }
return; return;
} }

View file

@ -25,6 +25,8 @@ import dev.loki.player.Survivor;
import dev.loki.ui.ActionBar; import dev.loki.ui.ActionBar;
import dev.loki.ui.TitleUI; import dev.loki.ui.TitleUI;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.GameMode; import org.bukkit.GameMode;
import org.bukkit.Location; import org.bukkit.Location;
@ -33,15 +35,20 @@ import org.bukkit.Sound;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public class GameplayPhase implements Phase { public class GameplayPhase implements Phase {
private enum EndgameState { NONE, PREGAME, POSTGAME }
private final GeneratorManager generatorManager; private final GeneratorManager generatorManager;
private int tickCounter; private int tickCounter;
private boolean shouldEnd; private boolean shouldEnd;
private boolean allGensDone; private boolean allGensDone;
private EndgameState endgameState = EndgameState.NONE;
private int endgameTimer; // секунд до смены состояния
// Sub-handlers // Sub-handlers
private GameplayHookHandler hookHandler; private GameplayHookHandler hookHandler;
@ -81,6 +88,8 @@ public class GameplayPhase implements Phase {
tickCounter = 0; tickCounter = 0;
shouldEnd = false; shouldEnd = false;
allGensDone = false; allGensDone = false;
endgameState = EndgameState.NONE;
endgameTimer = 0;
winConditions = new GameplayWinConditions(); winConditions = new GameplayWinConditions();
initManagers(session, plugin, cfg); initManagers(session, plugin, cfg);
@ -104,7 +113,6 @@ public class GameplayPhase implements Phase {
if (generatorRepair != null) generatorRepair.tick(); if (generatorRepair != null) generatorRepair.tick();
if (interaction != null) interaction.tickSurvivors(); if (interaction != null) interaction.tickSurvivors();
hookHandler.tick(); hookHandler.tick();
boolean timeExpired = ui.tickMatchTimer(session);
// Particle effects around generators (every 10 ticks) // Particle effects around generators (every 10 ticks)
if (tickCounter % 10 == 0 && generatorManager != null) { if (tickCounter % 10 == 0 && generatorManager != null) {
@ -119,19 +127,29 @@ public class GameplayPhase implements Phase {
} }
if (tickCounter % 20 == 0) { if (tickCounter % 20 == 0) {
tickEndgame(session);
checkGeneratorProgress(session); checkGeneratorProgress(session);
winConditions.checkWinConditions(session); if (endgameState == EndgameState.NONE) {
if (winConditions.isShouldEnd() || timeExpired) { winConditions.checkWinConditions(session);
shouldEnd = true; if (winConditions.isShouldEnd()) {
shouldEnd = true;
}
} else {
// Во время эндгейма игра заканчивается когда все выжившие мертвы/сбежали
if (allSurvivorsIncapacitated(session)) {
shouldEnd = true;
}
// Или когда истёк таймер после открытия ворот
if (endgameState == EndgameState.POSTGAME && endgameTimer <= 0) {
shouldEnd = true;
}
} }
ui.updateSidebar(session, generatorManager); ui.updateSidebar(session, generatorManager);
} }
if (timeExpired) { // Escape check — каждый тик для мгновенной реакции
showGameResults(session); if (endgameState == EndgameState.POSTGAME) {
teleportAllToLobby(session); checkEscapes(session);
session.stop();
return;
} }
} }
@ -193,6 +211,7 @@ public class GameplayPhase implements Phase {
hookRescue = new HookRescue(plugin, ab, cfg); hookRescue = new HookRescue(plugin, ab, cfg);
hookHandler = new GameplayHookHandler(hookManager, hookSacrifice, hookStruggle, hookRecovery); hookHandler = new GameplayHookHandler(hookManager, hookSacrifice, hookStruggle, hookRecovery);
hookHandler.setSession(session);
hookRescue.setOnRescueComplete((rescuer, victim) -> hookHandler.onRescued(victim)); hookRescue.setOnRescueComplete((rescuer, victim) -> hookHandler.onRescued(victim));
chestManager = new ChestManager(plugin, plugin.getConfigManager().getLocations(), cfg); chestManager = new ChestManager(plugin, plugin.getConfigManager().getLocations(), cfg);
@ -267,12 +286,30 @@ public class GameplayPhase implements Phase {
// Tick helpers // Tick helpers
// ========================================================================= // =========================================================================
/** Все выжившие мертвы или сбежали? */
private boolean allSurvivorsIncapacitated(GameSession session) {
boolean hasSurvivor = false;
for (BasePlayer bp : session.getPlayers().values()) {
if (bp.isKiller()) continue;
hasSurvivor = true;
PlayerState st = bp.getState();
if (st != PlayerState.DEAD && st != PlayerState.ESCAPED
&& st != PlayerState.DOWNED && st != PlayerState.HOOKED
&& st != PlayerState.CARRIED) {
return false;
}
}
return hasSurvivor;
}
private void checkGeneratorProgress(GameSession session) { private void checkGeneratorProgress(GameSession session) {
if (allGensDone) return; if (allGensDone) return;
if (generatorManager.areAllRequiredCompleted()) { if (generatorManager.areAllRequiredCompleted()) {
allGensDone = true; allGensDone = true;
interaction.setAllGensDone(true); interaction.setAllGensDone(true);
gateManager.activateEndgame(); gateManager.activateEndgame();
endgameState = EndgameState.PREGAME;
endgameTimer = session.getConfig().getPregameSeconds();
Bukkit.broadcast(session.getPlugin().getConfigManager().getMessages().get("generator.repair.all-done")); Bukkit.broadcast(session.getPlugin().getConfigManager().getMessages().get("generator.repair.all-done"));
for (BasePlayer bp : session.getPlayers().values()) { for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer(); Player p = bp.getPlayer();
@ -281,6 +318,63 @@ public class GameplayPhase implements Phase {
} }
} }
private void tickEndgame(GameSession session) {
if (endgameState == EndgameState.NONE) return;
endgameTimer--;
if (endgameState == EndgameState.PREGAME) {
// Каждую секунду шлём сообщение о начале эндгейма
if (endgameTimer > 0) {
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null) continue;
p.sendActionBar(net.kyori.adventure.text.Component.text(
"§c⚡ Эндгейм! Ворота откроются через §e" + endgameTimer + "с"));
}
} else {
// Таймер истёк — открываем ворота
gateManager.openAllGates();
endgameState = EndgameState.POSTGAME;
endgameTimer = session.getConfig().getPostgameSeconds();
for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer();
if (p == null) continue;
Title title = Title.title(
Component.text("ВОРОТА ОТКРЫТЫ!").color(NamedTextColor.GREEN),
Component.text("Бегите к выходу! (" + endgameTimer + "с)").color(NamedTextColor.YELLOW),
Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
);
p.showTitle(title);
p.playSound(p.getLocation(), Sound.BLOCK_IRON_DOOR_OPEN, 1.0f, 1.0f);
}
}
} else if (endgameState == EndgameState.POSTGAME) {
if (endgameTimer > 0 && endgameTimer % 10 == 0 && endgameTimer <= 30) {
Bukkit.broadcast(Component.text("§c⚠ До конца игры: §e" + endgameTimer + "с"));
}
}
}
private void checkEscapes(GameSession session) {
double escapeRange = session.getConfig().getEscapeRange();
for (BasePlayer bp : session.getPlayers().values()) {
if (bp.isKiller()) continue;
if (bp.getState() == PlayerState.ESCAPED) continue;
if (bp.getState() != PlayerState.HEALTHY && bp.getState() != PlayerState.INJURED) continue;
Survivor survivor = (Survivor) bp;
Player p = survivor.getPlayer();
if (p == null || !p.isOnline()) continue;
ExitGate gate = gateManager.getGateAt(p.getLocation(), escapeRange);
if (gate != null && gate.isOpen()) {
survivor.setState(PlayerState.ESCAPED);
p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1.0f, 1.2f);
Bukkit.broadcast(Component.text(
"§6[DBD] §a" + p.getName() + " §6сбежал через ворота!"));
}
}
}
private void spawnGameBlocks(mdbd plugin, GameConfig cfg) { private void spawnGameBlocks(mdbd plugin, GameConfig cfg) {
// Спавн блоков на местах генераторов // Спавн блоков на местах генераторов
Material genMat = Material.matchMaterial(cfg.getGeneratorBlock()); Material genMat = Material.matchMaterial(cfg.getGeneratorBlock());
@ -333,35 +427,6 @@ public class GameplayPhase implements Phase {
// Cleanup // Cleanup
// ========================================================================= // =========================================================================
private void showGameResults(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()) {
Player p = player.getPlayer();
String name = p != null ? p.getName() : "Unknown";
results.add("§c☠ " + name + " — Маньяк");
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);
}
}
private void teleportAllToLobby(GameSession session) { private void teleportAllToLobby(GameSession session) {
for (BasePlayer bp : session.getPlayers().values()) { for (BasePlayer bp : session.getPlayers().values()) {
Player p = bp.getPlayer(); Player p = bp.getPlayer();
@ -395,12 +460,11 @@ public class GameplayPhase implements Phase {
if (hookStruggle != null) hookStruggle.clear(); if (hookStruggle != null) hookStruggle.clear();
if (chestManager != null) chestManager.stopLootResetTimer(); if (chestManager != null) chestManager.stopLootResetTimer();
if (chestHologram != null) chestHologram.stop(); if (chestHologram != null) chestHologram.stop();
// Clean up gate blocks // Clean up gate blocks — don't reset gate state (EndgamePhase needs to know gates were open)
if (gateManager != null) { if (gateManager != null) {
for (ExitGate gate : gateManager.getAllGates()) { for (ExitGate gate : gateManager.getAllGates()) {
gate.removeGateBlock(); gate.removeGateBlock();
} }
gateManager.resetAll();
} }
session.getPlugin().getInteractListener().setGeneratorManager(null); session.getPlugin().getInteractListener().setGeneratorManager(null);
} }

View file

@ -4,8 +4,8 @@ import dev.loki.config.GameConfig;
import dev.loki.mdbd; import dev.loki.mdbd;
import dev.loki.player.PlayerState; import dev.loki.player.PlayerState;
import dev.loki.player.Survivor; import dev.loki.player.Survivor;
import dev.loki.util.TextUtil;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.UUID; import java.util.UUID;
@ -98,6 +98,10 @@ public class SurvivorHealing {
if (targetSurvivor == null) { cancelHealing(); return; } if (targetSurvivor == null) { cancelHealing(); return; }
PlayerState targetState = targetSurvivor.getState(); PlayerState targetState = targetSurvivor.getState();
if (targetState == PlayerState.DEAD || targetState == PlayerState.ESCAPED) {
cancelHealing();
return;
}
if (targetState != PlayerState.INJURED && targetState != PlayerState.DOWNED) { if (targetState != PlayerState.INJURED && targetState != PlayerState.DOWNED) {
completeHealing(targetSurvivor); completeHealing(targetSurvivor);
return; return;
@ -166,7 +170,7 @@ public class SurvivorHealing {
String bar = renderProgressBar(progress); String bar = renderProgressBar(progress);
String template = mdbd.getInstance().getConfigManager().getMessages().getString( String template = mdbd.getInstance().getConfigManager().getMessages().getString(
isSelfHeal ? "survivor.heal.self-start" : "survivor.heal.other-start", bar); isSelfHeal ? "survivor.heal.self-start" : "survivor.heal.other-start", bar);
Component message = MiniMessage.miniMessage().deserialize(template); Component message = TextUtil.color(template);
healingTarget.sendActionBar(message); healingTarget.sendActionBar(message);
// Also show on healer // Also show on healer
Player healer = controller.getPlayer(); Player healer = controller.getPlayer();

View file

@ -77,7 +77,7 @@ public class SurvivorMovement {
if (player == null) return; if (player == null) return;
if (isCrawling) { if (isCrawling) {
player.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 5, 2, false, false)); player.addPotionEffect(new PotionEffect(PotionEffectType.SLOW_FALLING, 10, 0, false, false));
} }
} }

View file

@ -4,6 +4,7 @@ import dev.loki.hook.Hook;
import dev.loki.hook.HookStage; import dev.loki.hook.HookStage;
import dev.loki.player.PlayerState; import dev.loki.player.PlayerState;
import dev.loki.player.Survivor; import dev.loki.player.Survivor;
import dev.loki.util.TextUtil;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
@ -12,7 +13,7 @@ import org.bukkit.entity.Player;
public class ActionBar { public class ActionBar {
public void sendMessage(Player player, String message) { public void sendMessage(Player player, String message) {
player.sendActionBar(Component.text(message)); player.sendActionBar(TextUtil.color(message));
} }
public void sendComponent(Player player, Component component) { public void sendComponent(Player player, Component component) {
@ -26,20 +27,20 @@ public class ActionBar {
public void showRepairProgress(Player player, double percent, boolean skillCheckActive) { public void showRepairProgress(Player player, double percent, boolean skillCheckActive) {
Component bar = ProgressBar.create(percent); Component bar = ProgressBar.create(percent);
TextComponent message = Component.text("Repairing ") TextComponent message = Component.text("Починка: ")
.color(NamedTextColor.WHITE) .color(NamedTextColor.WHITE)
.append(bar) .append(bar)
.append(Component.text(" " + (int) percent + "%").color(NamedTextColor.GRAY)); .append(Component.text(" " + (int) percent + "%").color(NamedTextColor.GRAY));
if (skillCheckActive) { if (skillCheckActive) {
message = message.append(Component.text(" ⚠ SKILL CHECK!").color(NamedTextColor.YELLOW)); message = message.append(Component.text(" ⚠ СКИЛЛ-ЧЕК!").color(NamedTextColor.YELLOW));
} }
player.sendActionBar(message); player.sendActionBar(message);
} }
public void showHealProgress(Player player, double percent, boolean selfHeal) { public void showHealProgress(Player player, double percent, boolean selfHeal) {
String label = selfHeal ? "Healing self" : "Healing ally"; String label = selfHeal ? "Лечение себя" : "Лечение союзника";
Component bar = ProgressBar.create(percent); Component bar = ProgressBar.create(percent);
Component message = Component.text(label + " ") Component message = Component.text(label + " ")
.color(NamedTextColor.WHITE) .color(NamedTextColor.WHITE)
@ -50,7 +51,7 @@ public class ActionBar {
public void showRescueProgress(Player player, double percent) { public void showRescueProgress(Player player, double percent) {
Component bar = ProgressBar.create(percent); Component bar = ProgressBar.create(percent);
Component message = Component.text("Rescuing ") Component message = Component.text("Спасение: ")
.color(NamedTextColor.WHITE) .color(NamedTextColor.WHITE)
.append(bar) .append(bar)
.append(Component.text(" " + (int) percent + "%").color(NamedTextColor.GRAY)); .append(Component.text(" " + (int) percent + "%").color(NamedTextColor.GRAY));
@ -71,15 +72,15 @@ public class ActionBar {
if (stage == HookStage.STAGE_1) { if (stage == HookStage.STAGE_1) {
stageIcon = StatusIcons.HOOK; stageIcon = StatusIcons.HOOK;
stageColor = NamedTextColor.YELLOW; stageColor = NamedTextColor.YELLOW;
stageName = "Stage 1"; stageName = "Стадия 1";
} else if (stage == HookStage.STAGE_2) { } else if (stage == HookStage.STAGE_2) {
stageIcon = StatusIcons.HOOK; stageIcon = StatusIcons.HOOK;
stageColor = NamedTextColor.RED; stageColor = NamedTextColor.RED;
stageName = "Stage 2 - STRUGGLE!"; stageName = "Стадия 2 — ВЫРВИСЬ!";
} else { } else {
stageIcon = StatusIcons.DEATH_SKULL; stageIcon = StatusIcons.DEATH_SKULL;
stageColor = NamedTextColor.DARK_RED; stageColor = NamedTextColor.DARK_RED;
stageName = "SACRIFICE IMMINENT"; stageName = "ЖЕРТВОПРИНОШЕНИЕ";
} }
Component bar = stage == HookStage.STAGE_2 Component bar = stage == HookStage.STAGE_2
@ -91,7 +92,7 @@ public class ActionBar {
.append(bar); .append(bar);
if (stage == HookStage.STAGE_2) { if (stage == HookStage.STAGE_2) {
message = message.append(Component.text(" CROUCH to struggle!").color(NamedTextColor.YELLOW)); message = message.append(Component.text(" ПРИСЯДЬ чтобы вырваться!").color(NamedTextColor.YELLOW));
} }
player.sendActionBar(message); player.sendActionBar(message);
@ -108,22 +109,22 @@ public class ActionBar {
private Component formatStatus(PlayerState state) { private Component formatStatus(PlayerState state) {
return switch (state) { return switch (state) {
case HEALTHY -> StatusIcons.HEALTHY_HEART case HEALTHY -> StatusIcons.HEALTHY_HEART
.append(Component.text(" Healthy").color(NamedTextColor.GREEN)); .append(Component.text(" Здоров").color(NamedTextColor.GREEN));
case INJURED -> StatusIcons.INJURED_HEART case INJURED -> StatusIcons.INJURED_HEART
.append(Component.text(" Injured").color(NamedTextColor.YELLOW)); .append(Component.text(" Ранен").color(NamedTextColor.YELLOW));
case DOWNED -> Component.text("🆘 Downed - need help!").color(NamedTextColor.RED); case DOWNED -> Component.text("🆘 В нокдауне — нужна помощь!").color(NamedTextColor.RED);
case HOOKED -> StatusIcons.HOOK case HOOKED -> StatusIcons.HOOK
.append(Component.text(" Hooked!").color(NamedTextColor.DARK_RED)); .append(Component.text(" На крюке!").color(NamedTextColor.DARK_RED));
case CARRIED -> Component.text("🎒 Being carried").color(NamedTextColor.RED); case CARRIED -> Component.text("🎒 Унесен маньяком").color(NamedTextColor.RED);
default -> Component.text(""); default -> Component.text("");
}; };
} }
public void showCarrying(Player killer, String survivorName) { public void showCarrying(Player killer, String survivorName) {
Component message = Component.text("Carrying: ") Component message = Component.text("Несет: ")
.color(NamedTextColor.WHITE) .color(NamedTextColor.WHITE)
.append(Component.text(survivorName).color(NamedTextColor.YELLOW)) .append(Component.text(survivorName).color(NamedTextColor.YELLOW))
.append(Component.text(" - Find a hook!").color(NamedTextColor.GRAY)); .append(Component.text(" — Найди крюк!").color(NamedTextColor.GRAY));
killer.sendActionBar(message); killer.sendActionBar(message);
} }

View file

@ -94,14 +94,14 @@ public class Sidebar {
// Clear ALL old entries in this objective first time only // Clear ALL old entries in this objective first time only
if (lastSeconds == null) { if (lastSeconds == null) {
for (String entry : new ArrayList<>(board.getEntries())) { for (String entry : new ArrayList<>(board.getEntries())) {
if (timerObj.getScore(entry).getScore() == 0 || entry.contains("⏱") || entry.contains("До конца")) { if (timerObj.getScore(entry).getScore() == 0 || entry.contains("⏱") || entry.contains("Time left")) {
board.resetScores(entry); board.resetScores(entry);
} }
} }
} }
// Use a FIXED entry key that never changes - only the score updates // Use a FIXED entry key that never changes - only the score updates
String timerEntry = "§6⏱ До конца: §c" + seconds + " сек"; String timerEntry = "§6⏱ Осталось: §c" + seconds + "с";
// Simply update the score - the entry persists // Simply update the score - the entry persists
Score score = timerObj.getScore(timerEntry); Score score = timerObj.getScore(timerEntry);
@ -124,7 +124,7 @@ public class Sidebar {
// Clear entries and hide // Clear entries and hide
for (String entry : board.getEntries()) { for (String entry : board.getEntries()) {
if (entry.contains("⏱") || entry.contains("До конца")) { if (entry.contains("⏱") || entry.contains("Осталось")) {
board.resetScores(entry); board.resetScores(entry);
} }
} }
@ -155,19 +155,17 @@ public class Sidebar {
if (endgameSecondsLeft >= 0) { if (endgameSecondsLeft >= 0) {
String timerColor = endgameSecondsLeft <= 30 ? "§c" : "§e"; String timerColor = endgameSecondsLeft <= 30 ? "§c" : "§e";
setScore(objective, "§8─────────────────", line--); setScore(objective, "§8─────────────────", line--);
setScore(objective, timerColor + "⏱ До конца: " + endgameSecondsLeft + " сек", line--); setScore(objective, timerColor + "⏱ Осталось: " + endgameSecondsLeft + "с", line--);
setScore(objective, "§8─────────────────", line--); setScore(objective, "§8─────────────────", line--);
} }
// Generator progress // Generator progress — use actual placed count, not config max
int completedGens = generatorManager != null ? generatorManager.getCompletedCount() : 0; int completedGens = generatorManager != null ? generatorManager.getCompletedCount() : 0;
int requiredGens = cfg.getGeneratorRequired(); int totalGens = generatorManager != null ? generatorManager.getTotalCount() : cfg.getGeneratorRequired();
int totalGens = cfg.getGeneratorTotal();
setScore(objective, "§eГенераторы:", line--); setScore(objective, "§eГенераторы:", line--);
String genBar = buildGenBar(completedGens, requiredGens); String genBar = buildGenBar(completedGens, totalGens);
setScore(objective, " §f" + completedGens + "/" + requiredGens + " " + genBar, line--); setScore(objective, " §f" + completedGens + "/" + totalGens + " " + genBar, line--);
setScore(objective, " §7(всего: " + totalGens + ")", line--);
setScore(objective, " ", line--); setScore(objective, " ", line--);
// Survivor list with hearts and colors // Survivor list with hearts and colors

View file

@ -33,16 +33,16 @@ public class TitleUI {
Component subtitle; Component subtitle;
if (isKiller) { if (isKiller) {
title = Component.text("KILLER") title = Component.text("МАНЬЯК")
.color(NamedTextColor.DARK_RED) .color(NamedTextColor.DARK_RED)
.decoration(TextDecoration.BOLD, true); .decoration(TextDecoration.BOLD, true);
subtitle = Component.text("Sacrifice them all") subtitle = Component.text("Принеси их в жертву")
.color(NamedTextColor.RED); .color(NamedTextColor.RED);
} else { } else {
title = Component.text("SURVIVOR") title = Component.text("ВЫЖИВШИЙ")
.color(NamedTextColor.DARK_GREEN) .color(NamedTextColor.DARK_GREEN)
.decoration(TextDecoration.BOLD, true); .decoration(TextDecoration.BOLD, true);
subtitle = Component.text("Repair generators and escape") subtitle = Component.text("Чини генераторы и выбирайся")
.color(NamedTextColor.GREEN); .color(NamedTextColor.GREEN);
} }
@ -57,17 +57,17 @@ public class TitleUI {
); );
Component title = escaped Component title = escaped
? Component.text("ESCAPED!") ? Component.text("СБЕЖАЛ!")
.color(NamedTextColor.GREEN) .color(NamedTextColor.GREEN)
.decoration(TextDecoration.BOLD, true) .decoration(TextDecoration.BOLD, true)
: Component.text("VICTORY!") : Component.text("ПОБЕДА!")
.color(NamedTextColor.DARK_RED) .color(NamedTextColor.DARK_RED)
.decoration(TextDecoration.BOLD, true); .decoration(TextDecoration.BOLD, true);
Component subtitle = escaped Component subtitle = escaped
? Component.text("You survived the night") ? Component.text("Ты пережил ночь")
.color(NamedTextColor.DARK_GREEN) .color(NamedTextColor.DARK_GREEN)
: Component.text("All survivors sacrificed") : Component.text("Все выжившие принесены в жертву")
.color(NamedTextColor.RED); .color(NamedTextColor.RED);
player.showTitle(Title.title(title, subtitle, times)); player.showTitle(Title.title(title, subtitle, times));
@ -80,11 +80,11 @@ public class TitleUI {
Duration.ofSeconds(1) Duration.ofSeconds(1)
); );
Component title = Component.text("DEFEAT") Component title = Component.text("ПОРАЖЕНИЕ")
.color(NamedTextColor.DARK_RED) .color(NamedTextColor.DARK_RED)
.decoration(TextDecoration.BOLD, true); .decoration(TextDecoration.BOLD, true);
Component subtitle = Component.text("The Entity claims all") Component subtitle = Component.text("Сущность забирает всех")
.color(NamedTextColor.RED); .color(NamedTextColor.RED);
player.showTitle(Title.title(title, subtitle, times)); player.showTitle(Title.title(title, subtitle, times));

View file

@ -195,12 +195,21 @@ endgame:
# Время открытия ворот (секунд) # Время открытия ворот (секунд)
gate-open-seconds: 20 gate-open-seconds: 20
# Секунд до открытия ворот (после починки всех ген)
pregame-seconds: 30
# Секунд после открытия ворот до конца игры
postgame-seconds: 60
# Открытие сундуков (секунд) # Открытие сундуков (секунд)
chest-open-seconds: 3 chest-open-seconds: 3
# Расстояние для взаимодействия с воротами (блоков) # Расстояние для взаимодействия с воротами (блоков)
gate-interaction-range: 3.0 gate-interaction-range: 3.0
# Расстояние до ворот для побега (блоков)
escape-range: 2.0
# Уведомление маньяка о генераторе # Уведомление маньяка о генераторе
killer-notification: killer-notification:
# Время действия компаса (секунд) # Время действия компаса (секунд)

View file

@ -116,6 +116,11 @@ setup:
survivor-spawn-placed: "%setup-prefix%&aСпавн для &6%s &aвыжившего установлен! (всего: %d/4) %s" survivor-spawn-placed: "%setup-prefix%&aСпавн для &6%s &aвыжившего установлен! (всего: %d/4) %s"
wand-point: "%setup-prefix%&a[Wand] Точка %d: %s" wand-point: "%setup-prefix%&a[Wand] Точка %d: %s"
map-cleared: "%setup-prefix%&aКарта очищена!" map-cleared: "%setup-prefix%&aКарта очищена!"
cleared: "%setup-prefix%&aВсе объекты удалены из конфига!"
cleared-type: "%setup-prefix%&aОбъекты &6%s &aудалены из конфига!"
delete-usage: "%setup-prefix%&cИспользование: /dbdsetup delete <тип> <номер>"
out-of-range: "%setup-prefix%&cИндекс &6%d &cвне диапазона!"
deleted: "%setup-prefix%&aОбъект &6%s &aудалён!"
spawns-cleared: "%setup-prefix%&aВсе спавны (киллер + выжившие) очищены!" spawns-cleared: "%setup-prefix%&aВсе спавны (киллер + выжившие) очищены!"
lobby-set: "%setup-prefix%&aЛобби установлено: %s" lobby-set: "%setup-prefix%&aЛобби установлено: %s"
@ -200,7 +205,7 @@ survivor:
actionbar: "&a⚕ %s %d%%" actionbar: "&a⚕ %s %d%%"
done: "%prefix%&a✔ Вы вылечились!" done: "%prefix%&a✔ Вы вылечились!"
cancelled: "%prefix%&eЛечение прервано." cancelled: "%prefix%&eЛечение прервано."
depleted: "&cYour medkit has been depleted!" depleted: "&cМедкит закончился!"
# ── Крюки ────────────────────────────────────── # ── Крюки ──────────────────────────────────────
@ -228,18 +233,22 @@ hook:
sacrifice: sacrifice:
message: "%prefix%&4Вы были принесены в жертву. Наблюдайте за матчем." message: "%prefix%&4Вы были принесены в жертву. Наблюдайте за матчем."
title: "☠" title: "☠"
subtitle: "&cYou have been sacrificed" subtitle: "&cВы были принесены в жертву"
broadcast: "%s has been sacrificed to the Entity" broadcast: "%s принесен в жертву Сущности"
# ── Сундуки ──────────────────────────────────── # ── Сундуки ────────────────────────────────────
chest: chest:
empty: "&cThis chest is empty!" empty: "&cСундук пуст!"
opening: "&eOpening chest..." opening: "&eОткрытие сундука..."
cancelled: "&cOpening cancelled!" cancelled: "&cОткрытие прервано!"
too-far: "%prefix%&cСлишком далеко!" too-far: "%prefix%&cСлишком далеко!"
actionbar: "&6Открытие: %s %d%%" actionbar: "&6Открытие: %s %d%%"
found: "%prefix%&aВы нашли: %s" found: "%prefix%&aВы нашли: %s"
# ── Ворота ─────────────────────────────────────
gate:
door-open: "&aОткрываем ворота... (зажмите ПКМ)" door-open: "&aОткрываем ворота... (зажмите ПКМ)"
door-opened: "&aВорота открыты!" door-opened: "&aВорота открыты!"
door-nearby: "&cНет ворот поблизости!" door-nearby: "&cНет ворот поблизости!"
@ -307,6 +316,8 @@ usage:
setup-killer-spawn: "&6/dbdsetup set killer-spawn — Спавн киллера (только 1)" setup-killer-spawn: "&6/dbdsetup set killer-spawn — Спавн киллера (только 1)"
setup-survivor-spawn: "&6/dbdsetup set survivor-spawn — Спавн выжившего (до 4)" setup-survivor-spawn: "&6/dbdsetup set survivor-spawn — Спавн выжившего (до 4)"
setup-gate: "&6/dbdsetup set gate A|B — Установить ворота (из точек ванда)" setup-gate: "&6/dbdsetup set gate A|B — Установить ворота (из точек ванда)"
setup-clear: "&6/dbdsetup clear [тип] — Очистить объекты (без разрушения блоков)"
setup-delete: "&6/dbdsetup delete <тип> <#> — Удалить конкретный объект"
setup-clear-spawns: "&6/dbdsetup clear-spawns — Очистить все спавны" setup-clear-spawns: "&6/dbdsetup clear-spawns — Очистить все спавны"
setup-lobby: "&6/dbdsetup lobby — Установить точку лобби" setup-lobby: "&6/dbdsetup lobby — Установить точку лобби"
admin-list-header: "&6=== DBD Admin ===" admin-list-header: "&6=== DBD Admin ==="