diff --git a/.gitignore b/.gitignore index 9e4b940..e9b421b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ hs_err_pid* # Common working directory run +bin/ +bin\ \ No newline at end of file diff --git a/PLAN.md b/PLAN.md index 8e3e231..9632a9f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 -├── 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 [password] [private]` — для обычных игроков (сейчас требует `mdbd.admin`) +- [ ] `/dbd join ` — вход в конкретное лобби -```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: "☠ МАНЬЯК РЯДОМ ☠" + 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" ``` diff --git a/bin/main/dev/loki/config/GameConfig.class b/bin/main/dev/loki/config/GameConfig.class index f15ca26..e057aaf 100644 Binary files a/bin/main/dev/loki/config/GameConfig.class and b/bin/main/dev/loki/config/GameConfig.class differ diff --git a/bin/main/dev/loki/core/GameSession$PlayerJoinEvent.class b/bin/main/dev/loki/core/GameSession$PlayerJoinEvent.class index 992a445..6e02676 100644 Binary files a/bin/main/dev/loki/core/GameSession$PlayerJoinEvent.class and b/bin/main/dev/loki/core/GameSession$PlayerJoinEvent.class differ diff --git a/bin/main/dev/loki/core/GameSession$PlayerLeaveEvent.class b/bin/main/dev/loki/core/GameSession$PlayerLeaveEvent.class index 932a970..ac4e284 100644 Binary files a/bin/main/dev/loki/core/GameSession$PlayerLeaveEvent.class and b/bin/main/dev/loki/core/GameSession$PlayerLeaveEvent.class differ diff --git a/bin/main/dev/loki/core/GameSession.class b/bin/main/dev/loki/core/GameSession.class index f572dbb..388d12a 100644 Binary files a/bin/main/dev/loki/core/GameSession.class and b/bin/main/dev/loki/core/GameSession.class differ diff --git a/bin/main/dev/loki/gate/ExitGate$GateState.class b/bin/main/dev/loki/gate/ExitGate$GateState.class index c9a1e1f..65b590a 100644 Binary files a/bin/main/dev/loki/gate/ExitGate$GateState.class and b/bin/main/dev/loki/gate/ExitGate$GateState.class differ diff --git a/bin/main/dev/loki/gate/ExitGate.class b/bin/main/dev/loki/gate/ExitGate.class index 0ff450d..210e7f1 100644 Binary files a/bin/main/dev/loki/gate/ExitGate.class and b/bin/main/dev/loki/gate/ExitGate.class differ diff --git a/bin/main/dev/loki/gate/GateManager.class b/bin/main/dev/loki/gate/GateManager.class index 4e5d554..09980d8 100644 Binary files a/bin/main/dev/loki/gate/GateManager.class and b/bin/main/dev/loki/gate/GateManager.class differ diff --git a/bin/main/dev/loki/gate/GateOpening$OpeningProgress.class b/bin/main/dev/loki/gate/GateOpening$OpeningProgress.class index cfa3d52..c261464 100644 Binary files a/bin/main/dev/loki/gate/GateOpening$OpeningProgress.class and b/bin/main/dev/loki/gate/GateOpening$OpeningProgress.class differ diff --git a/bin/main/dev/loki/gate/GateOpening$OpeningTask.class b/bin/main/dev/loki/gate/GateOpening$OpeningTask.class index e62ee19..31cfe44 100644 Binary files a/bin/main/dev/loki/gate/GateOpening$OpeningTask.class and b/bin/main/dev/loki/gate/GateOpening$OpeningTask.class differ diff --git a/bin/main/dev/loki/gate/GateOpening.class b/bin/main/dev/loki/gate/GateOpening.class index e11f561..008d3a0 100644 Binary files a/bin/main/dev/loki/gate/GateOpening.class and b/bin/main/dev/loki/gate/GateOpening.class differ diff --git a/bin/main/dev/loki/hook/Hook.class b/bin/main/dev/loki/hook/Hook.class index 42db737..272eb9d 100644 Binary files a/bin/main/dev/loki/hook/Hook.class and b/bin/main/dev/loki/hook/Hook.class differ diff --git a/bin/main/dev/loki/hook/HookManager.class b/bin/main/dev/loki/hook/HookManager.class index 14b0857..1c9fdc0 100644 Binary files a/bin/main/dev/loki/hook/HookManager.class and b/bin/main/dev/loki/hook/HookManager.class differ diff --git a/bin/main/dev/loki/hook/HookRescue$RescueSession.class b/bin/main/dev/loki/hook/HookRescue$RescueSession.class index fc1d5aa..0f20458 100644 Binary files a/bin/main/dev/loki/hook/HookRescue$RescueSession.class and b/bin/main/dev/loki/hook/HookRescue$RescueSession.class differ diff --git a/bin/main/dev/loki/hook/HookRescue.class b/bin/main/dev/loki/hook/HookRescue.class index c119423..98c0984 100644 Binary files a/bin/main/dev/loki/hook/HookRescue.class and b/bin/main/dev/loki/hook/HookRescue.class differ diff --git a/bin/main/dev/loki/hook/HookSabotage$1.class b/bin/main/dev/loki/hook/HookSabotage$1.class new file mode 100644 index 0000000..f14c4ee Binary files /dev/null and b/bin/main/dev/loki/hook/HookSabotage$1.class differ diff --git a/bin/main/dev/loki/hook/HookSabotage$2.class b/bin/main/dev/loki/hook/HookSabotage$2.class new file mode 100644 index 0000000..d296a49 Binary files /dev/null and b/bin/main/dev/loki/hook/HookSabotage$2.class differ diff --git a/bin/main/dev/loki/hook/HookSabotage.class b/bin/main/dev/loki/hook/HookSabotage.class new file mode 100644 index 0000000..0bf6cf5 Binary files /dev/null and b/bin/main/dev/loki/hook/HookSabotage.class differ diff --git a/bin/main/dev/loki/hook/HookSacrifice.class b/bin/main/dev/loki/hook/HookSacrifice.class index 730a908..c70e572 100644 Binary files a/bin/main/dev/loki/hook/HookSacrifice.class and b/bin/main/dev/loki/hook/HookSacrifice.class differ diff --git a/bin/main/dev/loki/hook/HookStruggle.class b/bin/main/dev/loki/hook/HookStruggle.class index f30fe78..0703637 100644 Binary files a/bin/main/dev/loki/hook/HookStruggle.class and b/bin/main/dev/loki/hook/HookStruggle.class differ diff --git a/bin/main/dev/loki/killer/GeneratorKick$1.class b/bin/main/dev/loki/killer/GeneratorKick$1.class new file mode 100644 index 0000000..4cb743a Binary files /dev/null and b/bin/main/dev/loki/killer/GeneratorKick$1.class differ diff --git a/bin/main/dev/loki/killer/GeneratorKick$2.class b/bin/main/dev/loki/killer/GeneratorKick$2.class new file mode 100644 index 0000000..01e4fd6 Binary files /dev/null and b/bin/main/dev/loki/killer/GeneratorKick$2.class differ diff --git a/bin/main/dev/loki/killer/GeneratorKick.class b/bin/main/dev/loki/killer/GeneratorKick.class new file mode 100644 index 0000000..e5bfc2c Binary files /dev/null and b/bin/main/dev/loki/killer/GeneratorKick.class differ diff --git a/bin/main/dev/loki/killer/KillerCarry$CarryData.class b/bin/main/dev/loki/killer/KillerCarry$CarryData.class index 7b8ff94..fb49924 100644 Binary files a/bin/main/dev/loki/killer/KillerCarry$CarryData.class and b/bin/main/dev/loki/killer/KillerCarry$CarryData.class differ diff --git a/bin/main/dev/loki/killer/KillerCarry.class b/bin/main/dev/loki/killer/KillerCarry.class index 62ae901..00fdac5 100644 Binary files a/bin/main/dev/loki/killer/KillerCarry.class and b/bin/main/dev/loki/killer/KillerCarry.class differ diff --git a/bin/main/dev/loki/killer/KillerController.class b/bin/main/dev/loki/killer/KillerController.class index 9ab4c7f..216eb72 100644 Binary files a/bin/main/dev/loki/killer/KillerController.class and b/bin/main/dev/loki/killer/KillerController.class differ diff --git a/bin/main/dev/loki/listener/PlayerInteractListener.class b/bin/main/dev/loki/listener/PlayerInteractListener.class index 0144bc2..cf58c1e 100644 Binary files a/bin/main/dev/loki/listener/PlayerInteractListener.class and b/bin/main/dev/loki/listener/PlayerInteractListener.class differ diff --git a/bin/main/dev/loki/listener/PlayerJumpListener.class b/bin/main/dev/loki/listener/PlayerJumpListener.class new file mode 100644 index 0000000..01b4ba1 Binary files /dev/null and b/bin/main/dev/loki/listener/PlayerJumpListener.class differ diff --git a/bin/main/dev/loki/objective/Generator.class b/bin/main/dev/loki/objective/Generator.class index e9bc4df..8ef29ff 100644 Binary files a/bin/main/dev/loki/objective/Generator.class and b/bin/main/dev/loki/objective/Generator.class differ diff --git a/bin/main/dev/loki/objective/GeneratorManager.class b/bin/main/dev/loki/objective/GeneratorManager.class index 1022585..854b4d2 100644 Binary files a/bin/main/dev/loki/objective/GeneratorManager.class and b/bin/main/dev/loki/objective/GeneratorManager.class differ diff --git a/bin/main/dev/loki/objective/GeneratorRegression.class b/bin/main/dev/loki/objective/GeneratorRegression.class index 5675bb5..82d411b 100644 Binary files a/bin/main/dev/loki/objective/GeneratorRegression.class and b/bin/main/dev/loki/objective/GeneratorRegression.class differ diff --git a/bin/main/dev/loki/objective/GeneratorRepair$RepairSession.class b/bin/main/dev/loki/objective/GeneratorRepair$RepairSession.class index 71881c8..7b8a34a 100644 Binary files a/bin/main/dev/loki/objective/GeneratorRepair$RepairSession.class and b/bin/main/dev/loki/objective/GeneratorRepair$RepairSession.class differ diff --git a/bin/main/dev/loki/objective/GeneratorRepair.class b/bin/main/dev/loki/objective/GeneratorRepair.class index a376b34..d19b18b 100644 Binary files a/bin/main/dev/loki/objective/GeneratorRepair.class and b/bin/main/dev/loki/objective/GeneratorRepair.class differ diff --git a/bin/main/dev/loki/phase/EndgamePhase$1.class b/bin/main/dev/loki/phase/EndgamePhase$1.class index 75e9380..3cd726c 100644 Binary files a/bin/main/dev/loki/phase/EndgamePhase$1.class and b/bin/main/dev/loki/phase/EndgamePhase$1.class differ diff --git a/bin/main/dev/loki/phase/EndgamePhase.class b/bin/main/dev/loki/phase/EndgamePhase.class index fe9c66e..c7ca7a7 100644 Binary files a/bin/main/dev/loki/phase/EndgamePhase.class and b/bin/main/dev/loki/phase/EndgamePhase.class differ diff --git a/bin/main/dev/loki/phase/GameplayPhase$VanillaDamageBlocker.class b/bin/main/dev/loki/phase/GameplayPhase$VanillaDamageBlocker.class index 07b8284..7294db6 100644 Binary files a/bin/main/dev/loki/phase/GameplayPhase$VanillaDamageBlocker.class and b/bin/main/dev/loki/phase/GameplayPhase$VanillaDamageBlocker.class differ diff --git a/bin/main/dev/loki/phase/GameplayPhase.class b/bin/main/dev/loki/phase/GameplayPhase.class index 319e538..2608d36 100644 Binary files a/bin/main/dev/loki/phase/GameplayPhase.class and b/bin/main/dev/loki/phase/GameplayPhase.class differ diff --git a/bin/main/dev/loki/phase/LobbyPhase.class b/bin/main/dev/loki/phase/LobbyPhase.class index 2f40852..8462a04 100644 Binary files a/bin/main/dev/loki/phase/LobbyPhase.class and b/bin/main/dev/loki/phase/LobbyPhase.class differ diff --git a/bin/main/dev/loki/role/RoleAssigner$RoleAssignedEvent.class b/bin/main/dev/loki/role/RoleAssigner$RoleAssignedEvent.class index a77863a..71b0e96 100644 Binary files a/bin/main/dev/loki/role/RoleAssigner$RoleAssignedEvent.class and b/bin/main/dev/loki/role/RoleAssigner$RoleAssignedEvent.class differ diff --git a/bin/main/dev/loki/role/RoleAssigner.class b/bin/main/dev/loki/role/RoleAssigner.class index 914d852..22c647a 100644 Binary files a/bin/main/dev/loki/role/RoleAssigner.class and b/bin/main/dev/loki/role/RoleAssigner.class differ diff --git a/bin/main/dev/loki/sound/TerrorRadiusSound.class b/bin/main/dev/loki/sound/TerrorRadiusSound.class index cdddeb4..5ae62d7 100644 Binary files a/bin/main/dev/loki/sound/TerrorRadiusSound.class and b/bin/main/dev/loki/sound/TerrorRadiusSound.class differ diff --git a/bin/main/dev/loki/survivor/SurvivorController.class b/bin/main/dev/loki/survivor/SurvivorController.class index ca4925b..20d29e6 100644 Binary files a/bin/main/dev/loki/survivor/SurvivorController.class and b/bin/main/dev/loki/survivor/SurvivorController.class differ diff --git a/bin/main/dev/loki/survivor/SurvivorHealing.class b/bin/main/dev/loki/survivor/SurvivorHealing.class index d4e33e6..07b6c86 100644 Binary files a/bin/main/dev/loki/survivor/SurvivorHealing.class and b/bin/main/dev/loki/survivor/SurvivorHealing.class differ diff --git a/bin/main/dev/loki/ui/BossBarManager$1.class b/bin/main/dev/loki/ui/BossBarManager$1.class new file mode 100644 index 0000000..cec8d5a Binary files /dev/null and b/bin/main/dev/loki/ui/BossBarManager$1.class differ diff --git a/bin/main/dev/loki/ui/BossBarManager.class b/bin/main/dev/loki/ui/BossBarManager.class new file mode 100644 index 0000000..ea4be78 Binary files /dev/null and b/bin/main/dev/loki/ui/BossBarManager.class differ diff --git a/bin/main/dev/loki/ui/Sidebar.class b/bin/main/dev/loki/ui/Sidebar.class index 17bd6ed..7734a96 100644 Binary files a/bin/main/dev/loki/ui/Sidebar.class and b/bin/main/dev/loki/ui/Sidebar.class differ diff --git a/bin/main/game.yml b/bin/main/game.yml index 34f4812..191ce21 100644 --- a/bin/main/game.yml +++ b/bin/main/game.yml @@ -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: "☠ МАНЬЯК РЯДОМ ☠" + + # Цвет: 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 diff --git a/src/main/java/dev/loki/config/GameConfig.java b/src/main/java/dev/loki/config/GameConfig.java index ee89075..b19d807 100644 --- a/src/main/java/dev/loki/config/GameConfig.java +++ b/src/main/java/dev/loki/config/GameConfig.java @@ -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", "�FF00"); - setDefault("colors.progress-bar-incomplete", "&#FF0000"); - setDefault("colors.progress-bar-background", "񑘕"); - setDefault("colors.hp-healthy", "�FF00"); - setDefault("colors.hp-injured", "&#FFA500"); - setDefault("colors.hp-downed", "&#FF0000"); - setDefault("colors.hp-dead", "B0000"); - - // 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", "☠ МАНЬЯК РЯДОМ ☠"); + 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", "�FF00"); + setDefault("colors.progress-bar-incomplete", "&#FF0000"); + setDefault("colors.progress-bar-background", "񑘕"); // 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", "☠ МАНЬЯК РЯДОМ ☠"); } + 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; } } diff --git a/src/main/java/dev/loki/core/GameSession.java b/src/main/java/dev/loki/core/GameSession.java index 53ce2e4..2e14d0b 100644 --- a/src/main/java/dev/loki/core/GameSession.java +++ b/src/main/java/dev/loki/core/GameSession.java @@ -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; } diff --git a/src/main/java/dev/loki/gate/ExitGate.java b/src/main/java/dev/loki/gate/ExitGate.java index 310c0b7..8074653 100644 --- a/src/main/java/dev/loki/gate/ExitGate.java +++ b/src/main/java/dev/loki/gate/ExitGate.java @@ -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; } diff --git a/src/main/java/dev/loki/gate/GateManager.java b/src/main/java/dev/loki/gate/GateManager.java index 923547e..074604e 100644 --- a/src/main/java/dev/loki/gate/GateManager.java +++ b/src/main/java/dev/loki/gate/GateManager.java @@ -75,9 +75,10 @@ public class GateManager { public void activateEndgame() { endgameActive = true; + // Только разблокировать ворота — игрок должен открывать их вручную for (ExitGate gate : gates) { gate.setPowered(true); - gate.startOpening(); + // НЕ вызываем gate.startOpening() — ворота открываются игроком } } diff --git a/src/main/java/dev/loki/gate/GateOpening.java b/src/main/java/dev/loki/gate/GateOpening.java index 0976f19..08f2ec4 100644 --- a/src/main/java/dev/loki/gate/GateOpening.java +++ b/src/main/java/dev/loki/gate/GateOpening.java @@ -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(); } diff --git a/src/main/java/dev/loki/hook/Hook.java b/src/main/java/dev/loki/hook/Hook.java index f41da59..2460e8d 100644 --- a/src/main/java/dev/loki/hook/Hook.java +++ b/src/main/java/dev/loki/hook/Hook.java @@ -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; } } diff --git a/src/main/java/dev/loki/hook/HookManager.java b/src/main/java/dev/loki/hook/HookManager.java index ba8ae08..f3cccfd 100644 --- a/src/main/java/dev/loki/hook/HookManager.java +++ b/src/main/java/dev/loki/hook/HookManager.java @@ -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 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 hookLocations = config.getHooks(); @@ -50,7 +56,7 @@ public class HookManager { public Optional 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); diff --git a/src/main/java/dev/loki/hook/HookRescue.java b/src/main/java/dev/loki/hook/HookRescue.java index e332e47..c87425c 100644 --- a/src/main/java/dev/loki/hook/HookRescue.java +++ b/src/main/java/dev/loki/hook/HookRescue.java @@ -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 activeRescues; - private static final int RESCUE_DURATION_TICKS = 40; - private static final double RESCUE_RANGE = 3.0; + private final GameConfig config; + private final Map 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, "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, "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; } } diff --git a/src/main/java/dev/loki/hook/HookSabotage.java b/src/main/java/dev/loki/hook/HookSabotage.java new file mode 100644 index 0000000..092c67d --- /dev/null +++ b/src/main/java/dev/loki/hook/HookSabotage.java @@ -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 sabotageCharging = new HashMap<>(); + /** hookUUID → recovery task */ + private final Map 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); + } +} diff --git a/src/main/java/dev/loki/hook/HookSacrifice.java b/src/main/java/dev/loki/hook/HookSacrifice.java index f50d399..da7dd3c 100644 --- a/src/main/java/dev/loki/hook/HookSacrifice.java +++ b/src/main/java/dev/loki/hook/HookSacrifice.java @@ -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); } diff --git a/src/main/java/dev/loki/hook/HookStruggle.java b/src/main/java/dev/loki/hook/HookStruggle.java index 5ff4fc8..da15960 100644 --- a/src/main/java/dev/loki/hook/HookStruggle.java +++ b/src/main/java/dev/loki/hook/HookStruggle.java @@ -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 struggleTasks; private final Map 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); diff --git a/src/main/java/dev/loki/killer/GeneratorKick.java b/src/main/java/dev/loki/killer/GeneratorKick.java new file mode 100644 index 0000000..f7cb0f6 --- /dev/null +++ b/src/main/java/dev/loki/killer/GeneratorKick.java @@ -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 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); + } +} diff --git a/src/main/java/dev/loki/killer/KillerCarry.java b/src/main/java/dev/loki/killer/KillerCarry.java index 3b75715..3464f93 100644 --- a/src/main/java/dev/loki/killer/KillerCarry.java +++ b/src/main/java/dev/loki/killer/KillerCarry.java @@ -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 activeCarries; + private final Map activeCarries = new HashMap<>(); private BukkitTask carryTask; + // Separate map: killerUUID -> carried survivor UUID (for jump listener) + private final Map 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 + "%")); } } diff --git a/src/main/java/dev/loki/killer/KillerController.java b/src/main/java/dev/loki/killer/KillerController.java index 10c098e..e83ffff 100644 --- a/src/main/java/dev/loki/killer/KillerController.java +++ b/src/main/java/dev/loki/killer/KillerController.java @@ -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(); diff --git a/src/main/java/dev/loki/listener/PlayerInteractListener.java b/src/main/java/dev/loki/listener/PlayerInteractListener.java index 4808fa5..74bec66 100644 --- a/src/main/java/dev/loki/listener/PlayerInteractListener.java +++ b/src/main/java/dev/loki/listener/PlayerInteractListener.java @@ -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 onRescueAttempt; private BiConsumer onChestInteract; private BiConsumer onHatchInteract; + /** Called when killer toggles sneak: (player, isSneaking) */ + private BiConsumer onKillerSneak; private final Map lastClick = new ConcurrentHashMap<>(); private static final long COOLDOWN_MS = 200; @@ -53,6 +56,16 @@ public class PlayerInteractListener implements Listener { public void setOnRescueAttempt(BiConsumer h) { this.onRescueAttempt = h; } public void setOnChestInteract(BiConsumer h) { this.onChestInteract = h; } public void setOnHatchInteract(BiConsumer h) { this.onHatchInteract = h; } + public void setOnKillerSneak(BiConsumer 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) { diff --git a/src/main/java/dev/loki/listener/PlayerJumpListener.java b/src/main/java/dev/loki/listener/PlayerJumpListener.java new file mode 100644 index 0000000..f824cdb --- /dev/null +++ b/src/main/java/dev/loki/listener/PlayerJumpListener.java @@ -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 onJump; + private final Set registered = new HashSet<>(); + + public PlayerJumpListener(Plugin plugin) { + this.plugin = plugin; + } + + public void setOnJump(Consumer 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); + } + } + } +} diff --git a/src/main/java/dev/loki/objective/Generator.java b/src/main/java/dev/loki/objective/Generator.java index c37ebad..58d494f 100644 --- a/src/main/java/dev/loki/objective/Generator.java +++ b/src/main/java/dev/loki/objective/Generator.java @@ -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; } diff --git a/src/main/java/dev/loki/objective/GeneratorManager.java b/src/main/java/dev/loki/objective/GeneratorManager.java index be1b5b7..f8ac492 100644 --- a/src/main/java/dev/loki/objective/GeneratorManager.java +++ b/src/main/java/dev/loki/objective/GeneratorManager.java @@ -12,12 +12,10 @@ import java.util.Map; public class GeneratorManager { private final Map 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 getAllGenerators() { - return new ArrayList<>(generators.values()); - } + public List getAllGenerators() { return new ArrayList<>(generators.values()); } public List getIncompleteGenerators() { List 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(); } diff --git a/src/main/java/dev/loki/objective/GeneratorRegression.java b/src/main/java/dev/loki/objective/GeneratorRegression.java index 7390102..012a28d 100644 --- a/src/main/java/dev/loki/objective/GeneratorRegression.java +++ b/src/main/java/dev/loki/objective/GeneratorRegression.java @@ -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); - } } diff --git a/src/main/java/dev/loki/objective/GeneratorRepair.java b/src/main/java/dev/loki/objective/GeneratorRepair.java index ee11d5f..fc67208 100644 --- a/src/main/java/dev/loki/objective/GeneratorRepair.java +++ b/src/main/java/dev/loki/objective/GeneratorRepair.java @@ -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("Отлично! +5%")); } else { generator.removeProgress(10.0); - generator.setKicked(true); - generator.setRegressionTicks(600); + generator.setUnderRegression(true, 600); player.sendMessage(MiniMessage.miniMessage().deserialize("Ошибка! Потеря прогресса!")); 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( + "[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); } } diff --git a/src/main/java/dev/loki/phase/EndgamePhase.java b/src/main/java/dev/loki/phase/EndgamePhase.java index c9c3e50..ac16dd9 100644 --- a/src/main/java/dev/loki/phase/EndgamePhase.java +++ b/src/main/java/dev/loki/phase/EndgamePhase.java @@ -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 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 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); + } + } } diff --git a/src/main/java/dev/loki/phase/GameplayPhase.java b/src/main/java/dev/loki/phase/GameplayPhase.java index 835a8db..e20fb5e 100644 --- a/src/main/java/dev/loki/phase/GameplayPhase.java +++ b/src/main/java/dev/loki/phase/GameplayPhase.java @@ -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 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) { diff --git a/src/main/java/dev/loki/phase/LobbyPhase.java b/src/main/java/dev/loki/phase/LobbyPhase.java index e3cfe68..3a69446 100644 --- a/src/main/java/dev/loki/phase/LobbyPhase.java +++ b/src/main/java/dev/loki/phase/LobbyPhase.java @@ -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)); } diff --git a/src/main/java/dev/loki/role/RoleAssigner.java b/src/main/java/dev/loki/role/RoleAssigner.java index 3cac413..a741487 100644 --- a/src/main/java/dev/loki/role/RoleAssigner.java +++ b/src/main/java/dev/loki/role/RoleAssigner.java @@ -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 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 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 uuids) { UUID queuedKiller = queue.nextKiller(); if (queuedKiller != null && uuids.contains(queuedKiller)) { diff --git a/src/main/java/dev/loki/sound/TerrorRadiusSound.java b/src/main/java/dev/loki/sound/TerrorRadiusSound.java index 7b282af..04eec63 100644 --- a/src/main/java/dev/loki/sound/TerrorRadiusSound.java +++ b/src/main/java/dev/loki/sound/TerrorRadiusSound.java @@ -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 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 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) { diff --git a/src/main/java/dev/loki/survivor/SurvivorController.java b/src/main/java/dev/loki/survivor/SurvivorController.java index 067f17a..b02c448 100644 --- a/src/main/java/dev/loki/survivor/SurvivorController.java +++ b/src/main/java/dev/loki/survivor/SurvivorController.java @@ -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; } } diff --git a/src/main/java/dev/loki/survivor/SurvivorHealing.java b/src/main/java/dev/loki/survivor/SurvivorHealing.java index 84af0d2..806906e 100644 --- a/src/main/java/dev/loki/survivor/SurvivorHealing.java +++ b/src/main/java/dev/loki/survivor/SurvivorHealing.java @@ -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 ? "Вы полностью вылечились!" - : "Вы встали на ноги!"; - 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 + ? "Вы полностью вылечились!" + : "Вы встали на ноги!"; + 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 { : "Лечение союзника: " + 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(""); - bar.append("█".repeat(filled)); - bar.append(""); - bar.append("░".repeat(empty)); - bar.append(" ").append((int)(progress * 100)).append("%"); - return bar.toString(); + int filled = (int)(10 * progress); + int empty = 10 - filled; + return "" + "█".repeat(filled) + "" + "░".repeat(empty) + + " " + (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); - } } diff --git a/src/main/java/dev/loki/ui/BossBarManager.java b/src/main/java/dev/loki/ui/BossBarManager.java new file mode 100644 index 0000000..6ef9a63 --- /dev/null +++ b/src/main/java/dev/loki/ui/BossBarManager.java @@ -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 survivorBossBars = new HashMap<>(); + + private Killer killer; + private Collection survivors; + private BukkitTask task; + + public BossBarManager(Plugin plugin, GameConfig config) { + this.plugin = plugin; + this.config = config; + } + + public void setTargets(Killer killer, Collection 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 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; + }; + } +} diff --git a/src/main/java/dev/loki/ui/Sidebar.java b/src/main/java/dev/loki/ui/Sidebar.java index fb6a609..facd30c 100644 --- a/src/main/java/dev/loki/ui/Sidebar.java +++ b/src/main/java/dev/loki/ui/Sidebar.java @@ -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 scoreboards; - public Sidebar() { - this.scoreboards = new HashMap<>(); - } + private final Map 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 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(); diff --git a/src/main/resources/game.yml b/src/main/resources/game.yml index 34f4812..191ce21 100644 --- a/src/main/resources/game.yml +++ b/src/main/resources/game.yml @@ -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: "☠ МАНЬЯК РЯДОМ ☠" + + # Цвет: 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