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

2
.gitignore vendored
View file

@ -19,3 +19,5 @@ hs_err_pid*
# Common working directory
run
bin/
bin\

463
PLAN.md
View file

@ -4,330 +4,203 @@
- **KISS**: Нет абстракций ради абстракций. Простые классы с одной ответственностью.
- **DRY**: Общая логика в утилитах, не копипаста.
- **SOLID**:
- SRP: Один класс = одна задача (StateManager только состояния, DamageHandler только урон)
- OCP: Перки через интерфейс, не if-else везде
- LSP: KillerController и SurvivorController наследуются от PlayerController корректно
- ISP: Маленькие интерфейсы (IRepairable, IHealable, IHookable)
- DIP: Высокоуровневые модули зависят от абстракций, не от имплементаций
- max 150 строк/файл
## Структура папок (max 150 строк/файл)
- **SOLID**: SRP, OCP, LSP, ISP, DIP. Max 150 строк/файл.
```
src/main/java/com/dbd/plugin/
├── DBDPlugin.java # Точка входа (max 50 строк)
├── config/
│ ├── ConfigManager.java # Загрузка всех yaml
│ ├── LocationsConfig.java # Координаты объектов
│ └── GameConfig.java # Балансные цифры
├── core/
│ ├── GameManager.java # Главный контроллер игр
│ ├── GameSession.java # Одна игра (lobby -> end)
│ ├── GameState.java # Enum: LOBBY, IN_GAME, ENDING
│ └── GameEventBus.java # Внутренние события игры
├── phase/
│ ├── Phase.java # Interface: enter(), tick(), exit()
│ ├── LobbyPhase.java # Ожидание игроков
│ ├── RoleAssignPhase.java # Назначение ролей, таймер 5сек
│ ├── GameplayPhase.java # Основная игра
│ └── EndgamePhase.java # Эндгейм коллапс + результаты
├── player/
│ ├── BasePlayer.java # UUID, Player объект
│ ├── Survivor.java # Данные выжившего
│ ├── Killer.java # Данные киллера
│ ├── PlayerState.java # Enum: HEALTHY, INJURED, DOWNED...
│ └── PlayerManager.java # Map<UUID, BasePlayer>
├── state/
│ ├── StateMachine.java # Машина состояний игрока
│ ├── StateTransition.java # Проверка можно ли перейти
│ └── StateHandler.java # Обработчик изменений
├── role/
│ ├── RoleAssigner.java # Логика выбора кто киллер
│ ├── RoleQueue.java # Очередь на киллера
│ └── SpawnPosition.java # Спавн-точки из конфига
├── survivor/
│ ├── SurvivorController.java # Действия выжившего
│ ├── SurvivorMovement.java # Sprint, Exhaustion
│ ├── SurvivorHealing.java # Лечение себя/других
│ └── SurvivorInventory.java # Предметы в руках
├── killer/
│ ├── KillerController.java # Действия киллера
│ ├── KillerAttack.java # ЛКМ, кулдаун, hit detection
│ ├── KillerCarry.java # Подъём, перенос, wiggle
│ └── KillerVision.java # Terror radius, red stain
├── damage/
│ ├── DamageHandler.java # Нанесение урона (Healthy->Injured...)
│ ├── AttackListener.java # Слушатель ЛКМ киллера
│ └── HitValidator.java # Line of sight, range check
├── objective/
│ ├── Generator.java # Один генератор
│ ├── GeneratorManager.java # Все 7 генераторов
│ ├── GeneratorRepair.java # Логика починки
│ ├── GeneratorRegression.java # Откат прогресса
│ ├── SkillCheck.java # QTE система
│ └── SkillCheckUI.java # Title-анимация
├── hook/
│ ├── Hook.java # Один крюк
│ ├── HookManager.java # Все крюки
│ ├── HookStage.java # Stage 1, Stage 2
│ ├── HookStruggle.java # Skill checks на Stage 2
│ ├── HookRescue.java # Спасание с крюка
│ └── HookSacrifice.java # Смерть после Stage 2
├── pallet/
│ ├── Pallet.java # Один паллет
│ ├── PalletManager.java # Все паллеты
│ ├── PalletDrop.java # Выживший бросает
│ ├── PalletStun.java # Стан киллера
│ └── PalletBreak.java # Киллер ломает
├── window/
│ ├── Window.java # Одно окно
│ ├── WindowManager.java # Все окна
│ ├── WindowVault.java # Прыжок через окно
│ └── WindowFastVault.java # Быстрый/медленный волт
├── gate/
│ ├── ExitGate.java # Одни ворота
│ ├── GateManager.java # 2 ворота
│ └── GateOpening.java # 20 сек открытие
├── hatch/
│ ├── Hatch.java # Люк
│ ├── HatchCondition.java # Условие появления
│ └── HatchEscape.java # Прыжок в люк
├── item/
│ ├── ItemType.java # Enum: MEDKIT, FLASHLIGHT...
│ ├── BaseItem.java # Interface предмета
│ ├── ItemMedkit.java # Аптечка
│ ├── ItemFlashlight.java # Фонарик
│ ├── ItemToolbox.java # Ящик инструментов
│ ├── ItemKey.java # Ключ от люка
│ ├── ItemMap.java # Карта
│ └── ChestLoot.java # Лут из сундуков
├── chest/
│ ├── Chest.java # Сундук
│ ├── ChestManager.java # Все сундуки
│ └── ChestOpening.java # 3 сек открытие
├── totem/
│ ├── Totem.java # Тотем
│ ├── TotemManager.java # 5 тотемов
│ ├── DullTotem.java # Обычный тотем
│ └── HexTotem.java # Проклятый (с перком)
├── perk/
│ ├── IPerk.java # Интерфейс перка
│ ├── PerkType.java # Enum: SURVIVOR, KILLER
│ ├── PerkRegistry.java # Map всех перков
│ ├── survivor/
│ │ ├── SelfCarePerk.java
│ │ ├── DeadHardPerk.java
│ │ ├── SprintBurstPerk.java
│ │ └── BorrowedTimePerk.java
│ └── killer/
│ ├── HexRuinPerk.java
│ ├── CorruptInterventionPerk.java
│ └── BBQChiliPerk.java
├── ui/
│ ├── ActionBar.java # Нижняя полоска
│ ├── Sidebar.java # Scoreboard
│ ├── TitleUI.java # Title/Subtitle
│ ├── ProgressBar.java # [████░░░░░░] 40%
│ └── StatusIcons.java # ❤ 🩸 💀
├── sound/
│ ├── SoundManager.java # Воспроизведение звуков
│ ├── TerrorRadiusSound.java # Сердцебиение
│ ├── GeneratorSound.java # Звуки генератора
│ └── SoundLevels.java # 3 уровня громкости
├── util/
│ ├── LocationUtil.java # Dist, line of sight
│ ├── PlayerUtil.java # Is sneaking, sprinting
│ ├── TimeUtil.java # Форматирование секунд
│ ├── MathUtil.java # Clamp, lerp
│ └── ParticleUtil.java # Спавн частиц
├── command/
│ ├── DBDCommand.java # /dbd главная
│ ├── JoinSubcommand.java # /dbd join
│ ├── LeaveSubcommand.java # /dbd leave
│ ├── SetupSubcommand.java # /dbd setgen, sethook...
│ ├── AdminSubcommand.java # /dbd forcestart, stop
│ └── StatsSubcommand.java # /dbd stats
├── listener/
│ ├── PlayerQuitListener.java # Дисконнект во время игры
│ ├── PlayerInteractListener.java # ПКМ, ЛКМ
│ ├── PlayerMoveListener.java # Движение, ползание
│ └── DamageListener.java # Отмена ванильного урона
├── data/
│ ├── DataStorage.java # Interface: save(), load()
│ ├── YamlStorage.java # YAML реализация
│ ├── SQLiteStorage.java # SQLite реализация (позже)
│ ├── PlayerStats.java # Статистика игрока
│ └── MatchResult.java # Результат матча
└── exception/
├── DBDException.java # Базовое исключение
├── InvalidLocationException.java
└── InvalidStateException.java
```
---
## TODO (по порядку)
## Статус реализации
### Этап 0: Скелет проекта
- [x] Создать `build.gradle` (Paper 1.20.6, Java 21, ProtocolLib)
### ✅ Этап 0: Скелет проекта
- [x] `build.gradle` (Paper 1.21.1, Java 21)
- [x] `plugin.yml` с пермишнами
- [x] `DBDPlugin.java` — onEnable/onDisable
- [x] `ConfigManager` — загрузка yaml без перезаписи
- [x] `LocationsConfig` — структура для всех координат
- [x] `GameConfig` — балансные цифры
- [x] `LocationsConfig` — координаты объектов
- [x] `GameConfig` — все балансные цифры (BossBar, carry, heal limits, lobby timers, sizes, kick cooldown, sabotage)
### Этап 1: Core Game Loop
### ✅ Этап 1: Core Game Loop
- [x] `GameState` enum
- [x] `GameSession` — хранит игроков, фазу, таймер
- [x] `GameSession` — хранит игроков, фазу, таймер; передаёт `GateManager` и `collapseSeconds` в `EndgamePhase`
- [x] `Phase` interface
- [x] `LobbyPhase` — ожидание, минимум 5 игроков, таймер 60сек
- [x] `RoleAssigner` + `RoleQueue` — кто киллер
- [x] `RoleAssignPhase` — телепорты, задержка 5сек
- [x] `GameplayPhase` — заглушка
- [x] `/dbd join`, `/dbd leave`, `/dbd forcestart`
- [x] `LobbyPhase` — таймер **45 сек при 4 игроках**, **15 сек при 5**, min-players=4, анонс на ключевых секундах
- [x] `RoleAssigner` + `RoleQueue` — выбор киллера; применяет `GENERIC_SCALE` через Attribute API
- [x] `RoleAssignPhase` — телепорты, задержка 5 сек
- [x] `GameplayPhase` — полная логика (см. ниже)
- [x] `EndgamePhase` — `collapseSeconds` из конфига, таймер в `Sidebar`, `GateManager` из сессии, Adventure API title
- [x] `/dbd join`, `/dbd leave`, `/dbd forcestart`, `/dbd stop`, `/dbd reload`, `/dbd lobby`, `/dbd setlobby`
### Этап 2: Игроки и состояния
### ✅ Этап 2: Игроки и состояния
- [x] `BasePlayer`, `Survivor`, `Killer`
- [x] `PlayerState` enum: HEALTHY, INJURED, DOWNED, HOOKED, DEAD, ESCAPED
- [x] `PlayerState`: HEALTHY, INJURED, DOWNED, HOOKED, CARRIED, DEAD, ESCAPED
- [x] `StateMachine` — переходы с валидацией
- [x] `PlayerManager` — map UUID -> BasePlayer
- [x] `PlayerManager` — map UUID → BasePlayer
- [x] `SpawnPosition` — телепорт из конфига
- [x] Перевод в `GameMode.SPECTATOR` при `PlayerState.DEAD` (в `GameplayPhase.checkWinConditions` и `HookSacrifice`)
- [x] Размеры через `Attribute.GENERIC_SCALE`: Survivor=1.0, Killer=1.5 (в `RoleAssigner` и `GameplayPhase`)
### Этап 3: Киллер (базовый)
- [x] `KillerController` — основной класс
- [x] `KillerAttack` — ЛКМ, кулдаун 2.5с, hitbox
### ✅ Этап 3: Киллер
- [x] `KillerController` — без дублирующей регистрации `AttackListener`
- [x] `KillerAttack` — ЛКМ, кулдаун 2.5 с, hitbox
- [x] `HitValidator` — line of sight, расстояние
- [x] `DamageHandler` — Healthy->Injured, Injured->Downed
- [x] `KillerCarry` — подъём, перенос, wiggle progress
- [x] `KillerMovement` — скорость 115%, замедление после удара
- [x] `DamageHandler` — Healthy→Injured, Injured→Downed
- [x] `KillerCarry` — побег через **спам пробела** (PlayerJumpListener), шкала в ActionBar, Speed I при побеге
- [x] `KillerMovement` — скорость из конфига, замедление после удара
- [x] `GeneratorKick` — Shift 3 сек → −20% прогресса, кулдаун 30 сек, progress bar
- [x] `BossBarManager` — BossBar с градиентом по дистанции до маньяка, читает все параметры из конфига
### Этап 4: Выживший (базовый)
- [x] `SurvivorController`
- [x] `SurvivorMovement` — sprint, exhaustion 3сек/5сек кулдаун
- [x] `SurvivorHealing` — аптечка 10сек, другие 16сек, прогресс-бар
### ✅ Этап 4: Выживший
- [x] `SurvivorController` — передаёт `GameConfig` в `SurvivorHealing`
- [x] `SurvivorMovement` — sprint, exhaustion
- [x] `SurvivorHealing` — таймеры из конфига (other=15 сек, self=30 сек), лимит **4 лечений** из конфига, sneak-check, range-check
- [x] Downed movement — crawl 30% скорости
### Этап 5: Генераторы
- [x] `Generator` класс — прогресс, статус
- [x] `GeneratorManager` — 7 штук, 5 нужно
- [x] `GeneratorRepair` — зажать ПКМ, прогресс в ActionBar
- [x] `SkillCheck` — логика QTE, успех/провал
- [x] `SkillCheckUI` — Title-анимация, круг (текстом или картинкой)
- [x] `GeneratorRegression` — откат 0.25%/сек, Kick 2.5%/сек 30сек
### ✅ Этап 5: Генераторы
- [x] `Generator` — `setUnderRegression(bool, ticks)`, `wasKicked()`
- [x] `GeneratorManager` — `getCompletedCount()` считает живое состояние
- [x] `GeneratorRepair` — ПКМ, прогресс в ActionBar; при фейле skillcheck: `setUnderRegression` + **компас маньяку** на `compass-duration-seconds`
- [x] `GeneratorRegression` — скорость полностью из конфига (`regression-rate`, `kick-regression-rate`), без хардкодированных констант
- [x] `SkillCheck` + `SkillCheckUI` — QTE система
### Этап 6: Крюки
- [x] `Hook` класс
- [x] `HookManager` — все крюки, подвал
- [x] `HookStage` — Stage 1 (60с), Stage 2 (60с)
- [x] `HookStruggle` — skill check каждые 2с
- [x] `HookRescue` — снятие 2сек
- [x] `HookSacrifice` — смерть
- [x] Счётчик крюков — 3 раза макс
### ✅ Этап 6: Крюки
- [x] `Hook` — поле `disabled` + `isDisabled()`/`setDisabled()`
- [x] `HookManager` — `maxHooks` из конфига, `tryHookSurvivor` проверяет лимит → при превышении сразу `DEAD`; `findNearestUnoccupiedHook` и `findAvailableHook` учитывают `isDisabled()`
- [x] `HookStage` — Stage 1 (60 с), Stage 2 (60 с)
- [x] `HookStruggle` — `struggleIntervalTicks` из конфига (`hooks.struggle-interval`)
- [x] `HookRescue` — 60 тиков (3 сек из конфига), sneak-check, range-check, Speed I после спасения
- [x] `HookSacrifice` — смерть + **SPECTATOR mode**
- [x] `HookSabotage` — выживший держит Shift **10 сек** → крюк отключён, восстановление **60 сек** (всё из конфига), progress bar, broadcast
### Этап 7: UI
### ✅ Этап 7: UI
- [x] `ActionBar` — статус, прогресс-бары
- [x] `ProgressBar` — [████░░░░░░] формат
- [x] `Sidebar` — генераторы, живые
- [x] `TitleUI` — начало игры, skill check, победа/поражение
- [x] `StatusIcons` — ❤ 🩸 💀
- [x] `Sidebar` — сердца ♥ (по `hooksTaken`), цвета ников по состоянию, `GeneratorManager` (живой счёт), таймер коллапса
- [x] `TitleUI`, `ProgressBar`, `StatusIcons`
- [x] `BossBarManager` — BossBar для каждого выжившего, градиент по дистанции
- [x] Шкала wiggle в `KillerCarry.showWiggleProgress()` (ActionBar)
### Этап 8: Паллеты и окна
- [x] `Pallet` — standing/dropped/broken
- [x] `PalletDrop` — ПКМ выжившим
- [x] `PalletStun` — 2.5сек стан киллеру
- [x] `PalletBreak` — зажать ПКМ 3сек
- [x] `Window` — класс
- [x] `WindowVault` — 1сек выживший, 1.5сек киллер
### ✅ Этап 8: Паллеты и окна
- [x] `Pallet`, `PalletManager`, `PalletDrop`, `PalletBreak`, `PalletStun`
- [x] `Window`, `WindowManager`, `WindowVault`, `WindowFastVault`
### Этап 9: Эндгейм
- [x] `ExitGate` — открытие 20сек
- [x] `GateManager` — 2 ворота
- [x] `Hatch` — условие появления
- [x] `HatchCondition` — формула: живые < починенные гены
- [x] `HatchEscape` — прыжок = побег
- [x] `EndgamePhase` — коллапс 120сек
### ✅ Этап 9: Ворота и люк
- [x] `ExitGate` — `savedProgress` + `getSavedProgress()`/`saveProgress()`/`restoreProgress()`/`setProgressDirect()`; `reset()` чистит `savedProgress`
- [x] `GateManager` — `activateEndgame()` только включает питание, **без авто-открытия**
- [x] `GateOpening` — **линейный прогресс** (`setProgressDirect`), сохраняет/восстанавливает прогресс при уходе/возврате игрока
- [x] `Hatch`, `HatchCondition`, `HatchEscape`
### Этап 10: Предметы
- [x] `ItemType` enum
- [x] `BaseItem` interface
- [x] `ItemMedkit`, `ItemFlashlight`, `ItemToolbox`, `ItemKey`, `ItemMap`
- [x] `Chest` — 3сек открытие
- [x] `ChestLoot` — весовая таблица
### ✅ Этап 10: Звук
- [x] `TerrorRadiusSound` — `terrorRadius` параметр конструктора (из конфига), дефолт 32
- [x] `SoundManager`, `GeneratorSound`, `SoundLevels`
### Этап 11: Звуки и Terror Radius
- [x] `SoundManager`
- [x] `TerrorRadiusSound` — 3 уровня по расстоянию
- [x] `KillerVision` — red stain частицы
- [x] Звуки генераторов, взрывы
### ✅ Этап 11: Слушатели
- [x] `PlayerJumpListener` — прыжки для wiggle при переноске
- [x] `PlayerInteractListener` — `setOnKillerSneak` + `PlayerToggleSneakEvent` (маньяк → GeneratorKick, выживший → HookSabotage)
- [x] `PlayerMoveListener`, `PlayerQuitListener`, `DamageListener`
### Этап 12: Data (YAML -> SQLite)
- [x] `DataStorage` interface
- [x] `YamlStorage` — реализация
- [x] `SQLiteStorage` — заглушка
- [x] `PlayerStats` — сохранение BP, матчей
- [x] `MatchResult` — результат матча
### ✅ Этап 12: Данные и хранение
- [x] `DataStorage`, `YamlStorage`, `SQLiteStorage`, `PlayerStats`, `MatchResult`
### Этап 13: Перки (отложено до стабильности)
- [x] `IPerk` interface: onEquip, onTrigger, onRemove
- [x] `PerkRegistry` — фабрика перков
- [x] 4 перка выжившего (Self-Care, Dead Hard, Sprint Burst, Borrowed Time)
- [x] 3 перка киллера (Hex: Ruin, Corrupt Intervention, BBQ)
- [x] `Totem` + `TotemManager` — только с Hex
---
### Этап 14: Полировка
- [x] Обработка дисконнекта
- [x] Баланс скоростей
- [x] Утилиты (LocationUtil, PlayerUtil, TimeUtil, MathUtil, ParticleUtil)
- [x] Оптимизация (проверка не каждый тик)
## ❌ Не реализовано (следующие этапы)
## Правила <150 строк на файл
### Пользовательские лобби
- [ ] `LobbySession` — пользовательское лобби (пароль, владелец, приватность)
- [ ] `LobbyRegistry` — реестр серверных и пользовательских лобби, YAML persistence
- [ ] `/dbd create <name> [password] [private]` — для обычных игроков (сейчас требует `mdbd.admin`)
- [ ] `/dbd join <name>` — вход в конкретное лобби
```java
// ✅ Правильно: Controller делегирует
public class KillerController {
private final KillerAttack attack;
private final KillerCarry carry;
private final KillerMovement movement;
public void onLeftClick(Player target) {
attack.tryAttack(target);
}
}
### Механики крюков
- [ ] `HookRecovery` — самостоятельное слезание с крюка на Stage 2
// ❌ Неправильно: всё в одном классе
public class KillerController {
// 500 строк логики атаки, переноса, движения...
}
```
## Ключевые интерфейсы
### IPerk
```java
public interface IPerk {
String getId();
String getName();
PerkType getType(); // SURVIVOR or KILLER
void onEquip(BasePlayer player);
void onTrigger(BasePlayer player, PerkTrigger trigger);
void onRemove(BasePlayer player);
}
```
### Phase
```java
public interface Phase {
void enter(GameSession session);
void tick(GameSession session); // каждую секунду
void exit(GameSession session);
boolean isComplete();
}
```
### DataStorage
```java
public interface DataStorage {
void saveStats(UUID player, PlayerStats stats);
PlayerStats loadStats(UUID player);
void saveMatch(MatchResult result);
}
### Перки
- [ ] `IPerk` интерфейс, `PerkRegistry`
- [ ] Survivor: `SelfCarePerk`, `DeadHardPerk`, `SprintBurstPerk`, `BorrowedTimePerk`
- [ ] Killer: `HexRuinPerk`, `CorruptInterventionPerk`, `BBQChiliPerk`
### Предметы из конфига
- [ ] `ChestLoot` — читать веса и типы предметов из `game.yml` (сейчас захардкожено)
---
## Конфиги (game.yml) — актуальные ключи
```yaml
lobby:
min-players: 4
max-players: 5
countdown-4-players: 45
countdown-5-players: 15
role-assign-delay: 5
players:
survivor-scale: 1.0
killer-scale: 1.5
generators:
total-count: 7
required-count: 5
repair-seconds: 90
regression-rate: 0.25
kick-regression-rate: 2.5
kick-regression-percent: 20.0
kick-duration-seconds: 30
kick-cooldown-seconds: 30
kick-charge-seconds: 3
interaction-range: 3.0
hooks:
stage1-seconds: 60
stage2-seconds: 60
struggle-interval: 2
max-hooks: 3
interaction-range: 3.0
rescue-seconds: 3
sabotage-seconds: 10
sabotage-recovery-seconds: 60
healing:
other-heal-seconds: 15
self-heal-seconds: 30
medkit-bonus: 0.5
interrupt-on-move: true
max-heals-per-match: 4
heal-range: 3.0
carry:
escape-jumps: 15
escape-speed-seconds: 3
jump-fill-percent: 6.67
killer:
attack-cooldown: 2.5
attack-range: 3.0
pallet-stun-seconds: 2.0
pallet-break-seconds: 3.0
terror-radius: 32
pallet-interaction-range: 3.0
bossbar:
enabled: true
title: "<red>☠ МАНЬЯК РЯДОМ ☠</red>"
color: RED
style: SOLID
appear-range: 32
full-range: 8
endgame:
gate-open-seconds: 20
collapse-seconds: 120
chest-open-seconds: 3
gate-interaction-range: 3.0
killer-notification:
compass-duration-seconds: 5
scoreboard:
show-hearts: true
heart-symbol: "♥"
color-healthy: "WHITE"
color-injured: "RED"
color-healing: "GREEN"
color-hooked: "DARK_RED"
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5,116 +5,194 @@
# Лобби
lobby:
# Минимум игроков для старта
min-players: 5
min-players: 4
# Максимум игроков
max-players: 5
# Обратный отсчёт (секунд)
countdown-seconds: 60
# Обратный отсчёт при 4 игроках (секунд)
countdown-4-players: 45
# Обратный отсчёт при 5 игроках (секунд)
countdown-5-players: 15
# Задержка перед назначением ролей (секунд)
role-assign-delay: 5
# Количество серверных лобби (0 = только пользовательские)
server-lobby-count: 3
# Сохранять лобби между перезапусками
persist-lobbies: true
# Размеры игроков (1.0 = стандарт)
players:
survivor-scale: 1.0
killer-scale: 1.5
# Генераторы
generators:
# Всего генераторов на карте
total-count: 7
# Сколько нужно починить для ворот
required-count: 5
# Время починки одного генератора (секунд)
repair-seconds: 80
# Время починки одного генератора (секунд) [90 сек по ТЗ]
repair-seconds: 90
# Скорость отката без чинильщика (%/сек)
regression-rate: 0.25
# Скорость отката после пинка (%/сек)
# Скорость отката после удара (%/сек)
kick-regression-rate: 2.5
# Длительность отката после пинка (секунд)
# Потеря прогресса после удара (%)
kick-regression-percent: 20.0
# Длительность отката после удара (секунд)
kick-duration-seconds: 30
# Кулдаун удара по генератору (секунд, общий для всех генераторов)
kick-cooldown-seconds: 30
# Время зажатия Shift для удара (секунд)
kick-charge-seconds: 3
# Расстояние для взаимодействия с генератором (блоков)
interaction-range: 3.0
# Крюки
hooks:
# Длительность Фазы 1 (секунд)
stage1-seconds: 60
# Длительность Фазы 2 (секунд)
stage2-seconds: 60
# Интервал борьбы на Фазе 2 (секунд)
struggle-interval: 2
# Максимум крюков на одного выжившего
# Максимум крюков на одного выжившего (= жизни)
max-hooks: 3
# Расстояние для взаимодействия с крюком (блоков)
interaction-range: 3.0
# Время спасения (секунд)
rescue-seconds: 3
# Время саботажа крюка (секунд)
sabotage-seconds: 10
# Время восстановления крюка (секунд)
sabotage-recovery-seconds: 60
# Скорости (% от нормальной)
movement:
# Киллер
killer: 115
# Киллер с выжившим
killer-carry: 92
# Выживший шагом
survivor-walk: 100
# Выживший спринт
survivor-sprint: 150
# Выживший в нокдауне (ползком)
survivor-crawl: 30
# Длительность истощения (секунд)
exhaustion-duration: 3
# Кулдаун истощения (секунд)
exhaustion-cooldown: 5
# Лечение
healing:
# Самолечение (секунд)
self-heal-seconds: 30
# Лечение другого (секунд)
other-heal-seconds: 16
other-heal-seconds: 15
# Самолечение аптечкой (секунд)
self-heal-seconds: 30
# Бонус аптечки (0.5 = в 2 раза быстрее)
medkit-bonus: 0.5
# Прерывание при движении
interrupt-on-move: true
# Максимум лечений за матч
max-heals-per-match: 4
# Расстояние лечения (блоков)
heal-range: 3.0
# Переноска выжившего
carry:
# Количество прыжков для заполнения шкалы побега
escape-jumps: 15
# Speed I после побега (секунд)
escape-speed-seconds: 3
# Приращение шкалы за прыжок (%)
jump-fill-percent: 6.67
# Киллер
killer:
# Кулдаун атаки (секунд)
attack-cooldown: 2.5
# Дальность атаки (блоков)
attack-range: 3.0
# Длительность оглушения паллетом (секунд)
pallet-stun-seconds: 2.5
pallet-stun-seconds: 2.0
# Время ломания паллета (секунд)
pallet-break-seconds: 3.0
# Радиус ужаса (блоков)
# Радиус страха (блоков)
terror-radius: 32
# Расстояние для взаимодействия с паллетом (блоков)
pallet-interaction-range: 3.0
# BossBar радиуса страха
bossbar:
# Включить BossBar
enabled: true
# Название (поддерживает MiniMessage)
title: "<red>☠ МАНЬЯК РЯДОМ ☠</red>"
# Цвет: PINK, BLUE, RED, GREEN, YELLOW, PURPLE, WHITE
color: RED
# Стиль: SOLID, SEGMENTED_6, SEGMENTED_10, SEGMENTED_12, SEGMENTED_20
style: SOLID
# Расстояние начала появления (блоков)
appear-range: 32
# Расстояние полного заполнения (блоков)
full-range: 8
# Окна
window:
# Быстрый волт выжившего (секунд)
vault-survivor-fast: 1.0
# Медленный волт выжившего (секунд)
vault-survivor-slow: 1.5
# Волт киллера (секунд)
vault-killer: 1.5
# Блокировка после 3 волтов подряд (секунд)
vault-block-seconds: 15
@ -122,17 +200,39 @@ window:
endgame:
# Время открытия ворот (секунд)
gate-open-seconds: 20
# Время до коллапса (секунд)
# Время до конца после открытия ворот (секунд)
collapse-seconds: 120
# Открытие сундуков (секунд)
chest-open-seconds: 3
# Расстояние для взаимодействия с воротами (блоков)
gate-interaction-range: 3.0
# Уведомление маньяка о генераторе
killer-notification:
# Время действия компаса (секунд)
compass-duration-seconds: 5
# Скорборд
scoreboard:
# Показывать сердца (жизни)
show-hearts: true
# Символ сердца
heart-symbol: "♥"
# Цвет ника: HEALTHY, INJURED (красный), HEALING (зелёный), HOOKED (тёмно-красный)
color-healthy: "WHITE"
color-injured: "RED"
color-healing: "GREEN"
color-hooked: "DARK_RED"
# Люк
hatch:
# Минимум живых для открытия люка
min-survivors-for-open: 1
# Условие: живых < починенных генераторов
require-more-gens-than-survivors: true

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();

View file

@ -5,116 +5,194 @@
# Лобби
lobby:
# Минимум игроков для старта
min-players: 5
min-players: 4
# Максимум игроков
max-players: 5
# Обратный отсчёт (секунд)
countdown-seconds: 60
# Обратный отсчёт при 4 игроках (секунд)
countdown-4-players: 45
# Обратный отсчёт при 5 игроках (секунд)
countdown-5-players: 15
# Задержка перед назначением ролей (секунд)
role-assign-delay: 5
# Количество серверных лобби (0 = только пользовательские)
server-lobby-count: 3
# Сохранять лобби между перезапусками
persist-lobbies: true
# Размеры игроков (1.0 = стандарт)
players:
survivor-scale: 1.0
killer-scale: 1.5
# Генераторы
generators:
# Всего генераторов на карте
total-count: 7
# Сколько нужно починить для ворот
required-count: 5
# Время починки одного генератора (секунд)
repair-seconds: 80
# Время починки одного генератора (секунд) [90 сек по ТЗ]
repair-seconds: 90
# Скорость отката без чинильщика (%/сек)
regression-rate: 0.25
# Скорость отката после пинка (%/сек)
# Скорость отката после удара (%/сек)
kick-regression-rate: 2.5
# Длительность отката после пинка (секунд)
# Потеря прогресса после удара (%)
kick-regression-percent: 20.0
# Длительность отката после удара (секунд)
kick-duration-seconds: 30
# Кулдаун удара по генератору (секунд, общий для всех генераторов)
kick-cooldown-seconds: 30
# Время зажатия Shift для удара (секунд)
kick-charge-seconds: 3
# Расстояние для взаимодействия с генератором (блоков)
interaction-range: 3.0
# Крюки
hooks:
# Длительность Фазы 1 (секунд)
stage1-seconds: 60
# Длительность Фазы 2 (секунд)
stage2-seconds: 60
# Интервал борьбы на Фазе 2 (секунд)
struggle-interval: 2
# Максимум крюков на одного выжившего
# Максимум крюков на одного выжившего (= жизни)
max-hooks: 3
# Расстояние для взаимодействия с крюком (блоков)
interaction-range: 3.0
# Время спасения (секунд)
rescue-seconds: 3
# Время саботажа крюка (секунд)
sabotage-seconds: 10
# Время восстановления крюка (секунд)
sabotage-recovery-seconds: 60
# Скорости (% от нормальной)
movement:
# Киллер
killer: 115
# Киллер с выжившим
killer-carry: 92
# Выживший шагом
survivor-walk: 100
# Выживший спринт
survivor-sprint: 150
# Выживший в нокдауне (ползком)
survivor-crawl: 30
# Длительность истощения (секунд)
exhaustion-duration: 3
# Кулдаун истощения (секунд)
exhaustion-cooldown: 5
# Лечение
healing:
# Самолечение (секунд)
self-heal-seconds: 30
# Лечение другого (секунд)
other-heal-seconds: 16
other-heal-seconds: 15
# Самолечение аптечкой (секунд)
self-heal-seconds: 30
# Бонус аптечки (0.5 = в 2 раза быстрее)
medkit-bonus: 0.5
# Прерывание при движении
interrupt-on-move: true
# Максимум лечений за матч
max-heals-per-match: 4
# Расстояние лечения (блоков)
heal-range: 3.0
# Переноска выжившего
carry:
# Количество прыжков для заполнения шкалы побега
escape-jumps: 15
# Speed I после побега (секунд)
escape-speed-seconds: 3
# Приращение шкалы за прыжок (%)
jump-fill-percent: 6.67
# Киллер
killer:
# Кулдаун атаки (секунд)
attack-cooldown: 2.5
# Дальность атаки (блоков)
attack-range: 3.0
# Длительность оглушения паллетом (секунд)
pallet-stun-seconds: 2.5
pallet-stun-seconds: 2.0
# Время ломания паллета (секунд)
pallet-break-seconds: 3.0
# Радиус ужаса (блоков)
# Радиус страха (блоков)
terror-radius: 32
# Расстояние для взаимодействия с паллетом (блоков)
pallet-interaction-range: 3.0
# BossBar радиуса страха
bossbar:
# Включить BossBar
enabled: true
# Название (поддерживает MiniMessage)
title: "<red>☠ МАНЬЯК РЯДОМ ☠</red>"
# Цвет: PINK, BLUE, RED, GREEN, YELLOW, PURPLE, WHITE
color: RED
# Стиль: SOLID, SEGMENTED_6, SEGMENTED_10, SEGMENTED_12, SEGMENTED_20
style: SOLID
# Расстояние начала появления (блоков)
appear-range: 32
# Расстояние полного заполнения (блоков)
full-range: 8
# Окна
window:
# Быстрый волт выжившего (секунд)
vault-survivor-fast: 1.0
# Медленный волт выжившего (секунд)
vault-survivor-slow: 1.5
# Волт киллера (секунд)
vault-killer: 1.5
# Блокировка после 3 волтов подряд (секунд)
vault-block-seconds: 15
@ -122,17 +200,39 @@ window:
endgame:
# Время открытия ворот (секунд)
gate-open-seconds: 20
# Время до коллапса (секунд)
# Время до конца после открытия ворот (секунд)
collapse-seconds: 120
# Открытие сундуков (секунд)
chest-open-seconds: 3
# Расстояние для взаимодействия с воротами (блоков)
gate-interaction-range: 3.0
# Уведомление маньяка о генераторе
killer-notification:
# Время действия компаса (секунд)
compass-duration-seconds: 5
# Скорборд
scoreboard:
# Показывать сердца (жизни)
show-hearts: true
# Символ сердца
heart-symbol: "♥"
# Цвет ника: HEALTHY, INJURED (красный), HEALING (зелёный), HOOKED (тёмно-красный)
color-healthy: "WHITE"
color-injured: "RED"
color-healing: "GREEN"
color-hooked: "DARK_RED"
# Люк
hatch:
# Минимум живых для открытия люка
min-survivors-for-open: 1
# Условие: живых < починенных генераторов
require-more-gens-than-survivors: true