diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/Dimension.java b/src/main/java/eu/mhsl/minenet/minigames/instance/Dimension.java index 1741355..b755bdc 100644 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/Dimension.java +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/Dimension.java @@ -5,6 +5,7 @@ import net.minestom.server.MinecraftServer; import net.minestom.server.registry.RegistryKey; import net.minestom.server.registry.RegistryTag; import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; import net.minestom.server.world.DimensionType; import net.minestom.server.world.attribute.EnvironmentAttribute; import net.minestom.server.world.clock.WorldClock; @@ -54,6 +55,20 @@ public enum Dimension { .defaultClock(WorldClock.THE_END) .timelines(RegistryTag.direct(Timeline.VILLAGER_SCHEDULE)) .build() + ), + + BACKROOMS( + Key.key("minenet:backrooms"), + DimensionType + .builder() + // Sehr dunkles Grundlicht (knapp ueber Vanilla-Overworld #0A0A0A): die eigentliche + // Helligkeit kommt aus echten Lichtquellen via LightingChunk — Blackout-Biome + // sind dadurch tatsaechlich dunkel, mit weichen Uebergaengen am Zonenrand + .setAttribute(EnvironmentAttribute.AMBIENT_LIGHT_COLOR, TextColor.color(0x16160F)) + .skybox(DimensionType.Skybox.NONE) + .defaultClock(WorldClock.OVERWORLD) + .timelines(RegistryTag.direct(Timeline.VILLAGER_SCHEDULE)) + .build() ); public final DimensionType DIMENSION; diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java index 504a143..5c6feff 100644 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java @@ -35,7 +35,7 @@ public enum GameList { STICKFIGHT(new StickFightFactory(), GameType.PROTOTYPE), TOWERDEFENSE(new TowerdefenseFactory(), GameType.PROTOTYPE), BEDWARS(new BedwarsFactory(), GameType.PROTOTYPE), - BACKROOMS(new BackroomsFactory(), GameType.PROTOTYPE), + BACKROOMS(new BackroomsFactory(), GameType.PVE), BOWSPLEEF(new BowSpleefFactory(), GameType.PROTOTYPE), TETRIS(new TetrisFactory(), GameType.OTHER), TNTRUN(new TntRunFactory(), GameType.OTHER), diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/Backrooms.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/Backrooms.java deleted file mode 100644 index f719a11..0000000 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/Backrooms.java +++ /dev/null @@ -1,20 +0,0 @@ -package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; - -import eu.mhsl.minenet.minigames.instance.Dimension; -import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame; -import eu.mhsl.minenet.minigames.score.NoScore; -import net.minestom.server.event.player.PlayerBlockBreakEvent; -import org.jetbrains.annotations.NotNull; - -public class Backrooms extends StatelessGame { - public Backrooms() { - super(Dimension.NETHER.key, "Backrooms", new NoScore()); - BackroomsGenerator generator = new BackroomsGenerator(); - this.setGenerator(unit -> generator.generateRoom(unit, 50)); - } - - @Override - protected void onBlockBreak(@NotNull PlayerBlockBreakEvent playerBlockBreakEvent) { - playerBlockBreakEvent.setCancelled(false); - } -} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsFactory.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsFactory.java index 73f3d7b..4277be6 100644 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsFactory.java +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsFactory.java @@ -1,21 +1,52 @@ package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; import eu.mhsl.minenet.minigames.instance.game.Game; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.ConfigManager; import eu.mhsl.minenet.minigames.instance.game.stateless.config.GameFactory; import eu.mhsl.minenet.minigames.instance.game.stateless.config.Option; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.common.NumericOption; import eu.mhsl.minenet.minigames.instance.room.Room; import eu.mhsl.minenet.minigames.message.component.TranslatedComponent; +import net.minestom.server.item.Material; import java.util.Map; public class BackroomsFactory implements GameFactory { @Override public TranslatedComponent name() { - return TranslatedComponent.byId("gameBackrooms"); + return TranslatedComponent.byId("game_Backrooms#name"); + } + + @Override + public TranslatedComponent description() { + return TranslatedComponent.byId("game_Backrooms#description"); + } + + @Override + public Material symbol() { + return Material.YELLOW_TERRACOTTA; + } + + @Override + public ConfigManager configuration() { + return new ConfigManager() + .addOption(new NumericOption("levels", Material.LADDER, TranslatedComponent.byId("game_Backrooms#optionLevels"), 3, 2, 4, 5)) + .addOption(new NumericOption("lives", Material.TOTEM_OF_UNDYING, TranslatedComponent.byId("game_Backrooms#optionLives"), 3, 1, 2, 5)) + .addOption(new NumericOption("monsterDensity", Material.ZOMBIE_HEAD, TranslatedComponent.byId("game_Backrooms#optionMonsterDensity"), 2, 1, 3)) + .addOption(new NumericOption("size", Material.MAP, TranslatedComponent.byId("game_Backrooms#optionSize"), 0, 1, 2, 3)) + // 0/1 statt BoolOption: deren erster Eintrag "true" waere sonst der Default + .addOption(new NumericOption("hardcoreSanity", Material.WITHER_ROSE, TranslatedComponent.byId("game_Backrooms#optionHardcoreSanity"), 0, 1)); } @Override public Game manufacture(Room parent, Map> configuration) throws Exception { - return new Backrooms().setParent(parent); + return new BackroomsGame( + configuration.get("levels").getAsInt(), + configuration.get("lives").getAsInt(), + configuration.get("monsterDensity").getAsInt(), + configuration.get("size").getAsInt(), + configuration.get("hardcoreSanity").getAsBoolean(), + parent.getAllMembers().size() + ).setParent(parent); } } diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsGame.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsGame.java new file mode 100644 index 0000000..f916d7a --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsGame.java @@ -0,0 +1,663 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; + +import eu.mhsl.minenet.minigames.instance.Dimension; +import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceEngine; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.door.DoorManager; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.BackroomsChunkGenerator; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.item.BackroomsItems; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.Level0Lobby; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.LevelFinale; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.LevelFun; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.LevelHabitableZone; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.LevelLightsOut; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.LevelPipeDreams; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl.LevelPoolrooms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.loot.ChestManager; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.sanity.SanityManager; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.transition.TransitionManager; +import eu.mhsl.minenet.minigames.message.Icon; +import eu.mhsl.minenet.minigames.message.component.TranslatedComponent; +import eu.mhsl.minenet.minigames.message.type.ChatMessage; +import eu.mhsl.minenet.minigames.message.type.TitleMessage; +import net.kyori.adventure.sound.Sound; +import java.time.Duration; +import net.kyori.adventure.text.Component; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.entity.EntityDamageEvent; +import net.minestom.server.event.player.PlayerBlockInteractEvent; +import net.minestom.server.event.player.PlayerMoveEvent; +import net.minestom.server.event.player.PlayerUseItemEvent; +import net.minestom.server.instance.LightingChunk; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.item.ItemStack; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.scoreboard.Sidebar; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.ExecutionType; +import net.minestom.server.timer.TaskSchedule; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Backrooms: Wettlauf durch prozedural generierte, vertikal gestapelte Horror-Level. + * Wer zuerst durch alle Level entkommt gewinnt; sterben alle anderen, gewinnt der + * letzte Ueberlebende. Kein Spieler-Schaden untereinander — nur die Backrooms selbst. + */ +public class BackroomsGame extends StatelessGame { + /** Zufalls-Pool der Zwischen-Level; wird mit jedem fertigen Leveltyp erweitert. */ + private static final List> LEVEL_POOL = List.of( + LevelHabitableZone::new, + LevelPipeDreams::new, + LevelPoolrooms::new, + LevelLightsOut::new, + LevelFun::new + ); + + private final List levels = new ArrayList<>(); + private final Map playerStates = new HashMap<>(); + private final Map sidebars = new HashMap<>(); + + private final int lives; + private final int monsterDensity; + private final boolean hardcoreSanity; + + private final TransitionManager transitionManager = new TransitionManager(this); + private final ChestManager chestManager; + private final DoorManager doorManager = new DoorManager(this); + private final SanityManager sanityManager; + private final AmbienceEngine ambienceEngine = new AmbienceEngine(this); + + private final Set openedDoorLevels = new HashSet<>(); + + private long tickCounter = 0; + private long secondCounter = 0; + + public BackroomsGame(int totalLevels, int lives, int monsterDensity, int sizeOption, boolean hardcoreSanity, int expectedPlayers) { + super(Dimension.BACKROOMS.key, "Backrooms", new BackroomsScore()); + this.getScore().setIgnoreLastPlayers(1); + this.lives = lives; + this.monsterDensity = monsterDensity; + this.hardcoreSanity = hardcoreSanity; + this.sanityManager = new SanityManager(this, hardcoreSanity); + + int cells = this.cellsForSize(sizeOption, expectedPlayers); + + List sequence = this.buildSequence(totalLevels); + for(int i = 0; i < sequence.size(); i++) { + LevelDefinition definition = sequence.get(i); + Random layoutRnd = new Random(this.rnd.nextLong()); + var layout = definition.buildLayout(layoutRnd, cells, expectedPlayers); + this.levels.add(new LevelRuntime(this, i, definition, layout, layoutRnd)); + } + + this.connectShaftLandings(); + // Echte Lichtberechnung: nur die Deckenlampen leuchten, Blackout-Biome sind + // tatsaechlich dunkel (Dimension-Ambient ist fast schwarz) + this.setChunkSupplier(LightingChunk::new); + this.setGenerator(new BackroomsChunkGenerator(this.levels)); + this.chestManager = new ChestManager(this); + + this.eventNode() + .addListener(PlayerBlockInteractEvent.class, event -> { + this.chestManager.onInteract(event); + this.doorManager.onInteract(event); + }) + .addListener(PlayerUseItemEvent.class, this::onUseItem) + .addListener(EntityAttackEvent.class, this::onEntityAttack) + .addListener(EntityDamageEvent.class, this::onEntityDamage); + } + + /** + * HOLE/LADDER-Schaechte enden im Level darunter an einer beliebigen Zelle. + * Die Landezelle wird freigeraeumt und per kuerzestem Wanddurchbruch mit dem + * Labyrinth verbunden, damit niemand in einer Wandtasche landet. + */ + private void connectShaftLandings() { + for(int i = 0; i < this.levels.size() - 1; i++) { + LevelRuntime upper = this.levels.get(i); + TransitionType transition = upper.getDefinition().transitionType(); + if(transition != TransitionType.HOLE && transition != TransitionType.LADDER) continue; + + int[] exit = upper.getLayout().getExitCell(); + if(exit == null) continue; + + var lowerLayout = this.levels.get(i + 1).getLayout(); + int landingX = lowerLayout.cellX(upper.getLayout().worldX(exit[0]) + 1); + int landingZ = lowerLayout.cellZ(upper.getLayout().worldZ(exit[1]) + 1); + if(!lowerLayout.inBounds(landingX, landingZ)) continue; + if(lowerLayout.isWalkable(landingX, landingZ)) continue; + + lowerLayout.setCell(landingX, landingZ, LevelLayout.Cell.FLOOR); + MazeAlgorithms.ensureConnected( + lowerLayout, + lowerLayout.getSpawnCells().getFirst(), + List.of(new int[]{landingX, landingZ}) + ); + } + } + + private int cellsForSize(int sizeOption, int expectedPlayers) { + int cells = switch(sizeOption) { + case 1 -> 25; + case 2 -> 37; + case 3 -> 49; + // Auto: waechst mit der Wurzel der Spielerzahl, damit 50 Spieler nicht clustern + default -> 23 + 5 * (int) Math.ceil(Math.sqrt(Math.max(1, expectedPlayers))); + }; + cells = Math.min(63, cells); + return cells % 2 == 0 ? cells + 1 : cells; + } + + private List buildSequence(int totalLevels) { + List sequence = new ArrayList<>(); + sequence.add(new Level0Lobby()); + + List> pool = new ArrayList<>(LEVEL_POOL); + Collections.shuffle(pool, this.rnd); + int middleCount = Math.max(0, totalLevels - 2); + for(int i = 0; i < middleCount; i++) { + // Pool leer oder aufgebraucht: Lobby-Varianten als Fallback + sequence.add(i < pool.size() ? pool.get(i).get() : new Level0Lobby()); + } + + sequence.add(new LevelFinale()); + return sequence; + } + + public List getLevels() { + return this.levels; + } + + public Random rnd() { + return this.rnd; + } + + public BackroomsPlayer playerState(Player player) { + return this.playerStates.get(player.getUuid()); + } + + public List playersInLevel(int index) { + return this.getPlayers().stream() + .filter(player -> player.getGameMode() == GameMode.ADVENTURE) + .filter(player -> { + BackroomsPlayer state = this.playerState(player); + return state != null && state.getLevelIndex() == index; + }) + .toList(); + } + + public boolean isDoorOpened(int levelIndex) { + return this.openedDoorLevels.contains(levelIndex); + } + + /** + * Erster Spieler oeffnet die Tuer: Nachricht an alle, die Keys dieses Levels + * zerfallen — und alle Spieler im Level bekommen automatisch einen Kompass, + * um schnell nachzukommen. + */ + public void markDoorOpened(int levelIndex) { + if(!this.openedDoorLevels.add(levelIndex)) return; + + this.getPlayers().forEach(player -> + BackroomsItems.removeMatching(player, item -> BackroomsItems.isKeyFor(item, levelIndex)) + ); + + this.playersInLevel(levelIndex).forEach(player -> this.chestManager.giveCompassIfMissing(player, levelIndex)); + + new ChatMessage(Icon.INFO) + .appendTranslated("game_Backrooms#doorOpened") + .send(this.playersInLevel(levelIndex)); + } + + public LevelRuntime levelAt(int y) { + for(LevelRuntime level : this.levels) { + if(y >= level.getBaseY() && y < level.getBaseY() + LevelRuntime.LEVEL_HEIGHT) return level; + } + return null; + } + + @Override + protected void onLoad(@NotNull CompletableFuture callback) { + callback.complete(null); + } + + @Override + protected void onStart() { + // onStart laeuft in einem CompletableFuture — jede unbehandelte Exception wuerde + // lautlos verschluckt und ALLE Spielsysteme (Monster, Events, Sanity) blieben tot. + // Deshalb: alles einzeln kapseln und Fehler laut ins Log schreiben. + try { + this.getPlayers().forEach(player -> { + try { + this.playerStates.put(player.getUuid(), new BackroomsPlayer(this.lives)); + player.setExp(1f); + player.setFood(20); + player.setFoodSaturation(0); + this.createSidebar(player); + } catch(Exception e) { + this.logger.severe("player setup failed for " + player.getUsername() + ": " + e); + MinecraftServer.getExceptionManager().handleException(e); + } + }); + + for(LevelRuntime level : this.levels) { + try { + level.getDefinition().populateMonsters(this, level, this.monsterDensity); + this.logger.info("Backrooms level " + level.getIndex() + " (" + + level.getDefinition().type() + "): " + level.getMonsters().size() + " monsters spawned"); + } catch(Exception e) { + this.logger.severe("populateMonsters failed for level " + level.getIndex() + ": " + e); + MinecraftServer.getExceptionManager().handleException(e); + } + } + + // Zentraler Spiel-Tick: Monster-AI (nur Level mit Spielern), No-Clip-Erkennung, Dunkelheits-Effekte + this.scheduler().submitTask(() -> { + if(!this.isRunning()) return TaskSchedule.stop(); + this.tickCounter += 2; + + for(LevelRuntime level : this.levels) { + List playersInLevel = level.playersInLevel(); + if(playersInLevel.isEmpty()) continue; + for(BackroomsMonster monster : List.copyOf(level.getMonsters())) { + if(monster.isRemoved() || monster.isDead()) { + monster.remove(); + level.getMonsters().remove(monster); + continue; + } + // Spawn laeuft asynchron ueber loadChunk — bis dahin nicht ticken + if(monster.getInstance() == null) continue; + // Nur in Spielernaehe ticken: dort sind die Chunks garantiert geladen, + // sonst wirft der Pathfinder auf ungeladenen Chunks NPEs + boolean playerNear = playersInLevel.stream().anyMatch(player -> monster.getDistance(player) < 48); + if(!playerNear) continue; + try { + monster.monsterTick(this.tickCounter); + } catch(Exception e) { + // Ein einziges kaputtes Monster darf nicht den ganzen AI-Task toeten + this.logger.severe("monsterTick failed, removing monster " + monster.getEntityType() + ": " + e); + MinecraftServer.getExceptionManager().handleException(e); + monster.remove(); + } + } + } + + if(this.tickCounter % 10 == 0) { + this.getPlayers().stream() + .filter(player -> player.getGameMode() == GameMode.ADVENTURE) + .forEach(player -> { + try { + this.transitionManager.onTick(player); + this.applyDarkness(player); + } catch(Exception e) { + MinecraftServer.getExceptionManager().handleException(e); + } + }); + } + + return TaskSchedule.tick(2); + }, ExecutionType.TICK_START); + + // Level-Events (Blackouts, Finale-Todeszone) + Monster-Director im Sekundentakt + this.scheduler().submitTask(() -> { + if(!this.isRunning()) return TaskSchedule.stop(); + this.secondCounter++; + this.getPlayers().stream() + .filter(player -> player.getGameMode() == GameMode.ADVENTURE) + .forEach(player -> { + try { + this.tickHunger(player); + } catch(Exception e) { + // darf den Level-Event-Task nicht dauerhaft toeten + MinecraftServer.getExceptionManager().handleException(e); + } + }); + for(LevelRuntime level : this.levels) { + if(level.playersInLevel().isEmpty()) continue; + try { + level.getDefinition().onLevelTick(this, level); + if(this.secondCounter % 5 == 0) this.directMonsters(level); + } catch(Exception e) { + this.logger.severe("onLevelTick failed for level " + level.getIndex() + ": " + e); + MinecraftServer.getExceptionManager().handleException(e); + } + } + return TaskSchedule.seconds(1); + }, ExecutionType.TICK_END); + + this.sanityManager.start(); + this.ambienceEngine.start(); + this.setTimeLimit(600 + this.levels.size() * 60); + } catch(Exception e) { + this.logger.severe("BackroomsGame.onStart failed: " + e); + MinecraftServer.getExceptionManager().handleException(e); + } + } + + /** + * Sprinten kostet Hunger (sichtbar durch den Hunger-Effekt); unter 7 Punkten + * verweigert der Client das Sprinten von selbst. Der Vorrat reicht fuer rund + * 30 Sekunden Dauersprint; ohne Sprint regeneriert er langsam, Energy Bars + * fuellen ihn schnell. + */ + private void tickHunger(Player player) { + if(player.isSprinting()) { + if(this.secondCounter % 2 == 0) player.setFood(Math.max(0, player.getFood() - 1)); + player.addEffect(new Potion(PotionEffect.HUNGER, (byte) 0, 35)); + } else if(this.secondCounter % 3 == 0) { + player.setFood(Math.min(20, player.getFood() + 1)); + } + } + + /** + * Monster-Director: haelt die Monsterpopulation in Spielernaehe. Roaming-Monster + * spawnen 6-14 Zellen (18-42 Bloecke) neben einem zufaelligen Spieler und despawnen, + * sobald sie von allen Spielern des Levels > 64 Bloecke entfernt sind. Fest platzierte + * Monster (Fallen wie Clumps, Smiler-Nester) bleiben bestehen. + */ + private void directMonsters(LevelRuntime level) { + List players = level.playersInLevel(); + if(players.isEmpty()) return; + + for(BackroomsMonster monster : List.copyOf(level.getMonsters())) { + if(monster.isRemoved() || !monster.isRoaming() || monster.getInstance() == null) continue; + boolean anyoneNear = players.stream().anyMatch(player -> monster.getDistance(player) < 64); + if(!anyoneNear) { + monster.remove(); + level.getMonsters().remove(monster); + } + } + + long active = level.getMonsters().stream().filter(monster -> !monster.isRemoved()).count(); + int target = this.monsterDensity + players.size(); + if(active >= target) return; + + var layout = level.getLayout(); + for(int attempt = 0; attempt < 10 && active < target; attempt++) { + Player anchor = players.get(this.rnd.nextInt(players.size())); + int cx = layout.cellX(anchor.getPosition().x()) + this.randomOffset(6, 14); + int cz = layout.cellZ(anchor.getPosition().z()) + this.randomOffset(6, 14); + if(!layout.isWalkable(cx, cz)) continue; + + // Nicht neben ein bereits vorhandenes Monster spawnen — sonst klumpen sie + Pos candidate = level.toPlayerPos(new int[]{cx, cz}); + boolean crowded = level.getMonsters().stream() + .anyMatch(other -> !other.isRemoved() && other.getInstance() != null + && other.getPosition().distance(candidate) < 12); + if(crowded) continue; + + BackroomsMonster monster = level.getDefinition().createRoamingMonster(level, new int[]{cx, cz}); + if(monster == null) continue; + monster.setRoaming(true); + monster.spawnAtCell(this, new int[]{cx, cz}); + level.getMonsters().add(monster); + active++; + } + } + + private int randomOffset(int min, int max) { + int magnitude = min + this.rnd.nextInt(max - min + 1); + return this.rnd.nextBoolean() ? magnitude : -magnitude; + } + + /** + * DARKNESS-Effekt nur noch fuer Blackout-Events (ploetzlicher Lichtausfall) — + * dauerhaft dunkle Bereiche inkl. Lights Out verdunkelt bereits die echte + * Lichtberechnung; beides zusammen waere komplett blind. + */ + private void applyDarkness(Player player) { + BackroomsPlayer state = this.playerState(player); + if(state == null) return; + LevelRuntime level = this.levels.get(state.getLevelIndex()); + if(level.isBlackoutActive()) player.addEffect(new Potion(PotionEffect.DARKNESS, (byte) 0, 40)); + } + + @Override + protected void onStop() { + // Sidebars abrauemen — sonst nehmen die Spieler das Scoreboard mit in die Lobby + this.sidebars.values().forEach(sidebar -> + Set.copyOf(sidebar.getViewers()).forEach(sidebar::removeViewer) + ); + this.sidebars.clear(); + + // Leben und Hunger zuruecksetzen — der Lobby-Reset des Frameworks macht das nicht + this.getPlayers().forEach(this::restoreVitals); + } + + @Override + protected void onPlayerLeave(Player player) { + Sidebar sidebar = this.sidebars.remove(player.getUuid()); + if(sidebar != null) sidebar.removeViewer(player); + this.restoreVitals(player); + super.onPlayerLeave(player); + } + + private void restoreVitals(Player player) { + player.setHealth((float) player.getAttributeValue(Attribute.MAX_HEALTH)); + player.setFood(20); + player.setFoodSaturation(5); + } + + @Override + protected void onPlayerMove(@NotNull PlayerMoveEvent event) { + if(this.isBeforeBeginning) { + // Vor dem Start eingefroren: Umschauen erlaubt, Position bleibt fix + if(!event.getNewPosition().samePoint(event.getPlayer().getPosition())) { + event.setCancelled(true); + } + return; + } + this.transitionManager.onMove(event.getPlayer(), event.getNewPosition()); + } + + private void onUseItem(PlayerUseItemEvent event) { + Player player = event.getPlayer(); + ItemStack item = event.getItemStack(); + + if(BackroomsItems.is(item, BackroomsItems.ALMOND_WATER)) { + event.setCancelled(true); + this.consumeOne(player, event); + this.sanityManager.restore(player, 0.4); + float maxHealth = (float) player.getAttributeValue(Attribute.MAX_HEALTH); + player.setHealth(Math.min(maxHealth, player.getHealth() + 4)); + player.playSound(Sound.sound(SoundEvent.ENTITY_GENERIC_DRINK, Sound.Source.PLAYER, 1f, 1f), Sound.Emitter.self()); + } else if(BackroomsItems.is(item, BackroomsItems.ENERGY_BAR)) { + event.setCancelled(true); + this.consumeOne(player, event); + player.setFood(Math.min(20, player.getFood() + 8)); + player.playSound(Sound.sound(SoundEvent.ENTITY_PLAYER_BURP, Sound.Source.PLAYER, 1f, 1f), Sound.Emitter.self()); + } + } + + private void consumeOne(Player player, PlayerUseItemEvent event) { + ItemStack item = event.getItemStack(); + player.setItemInHand(event.getHand(), item.withAmount(item.amount() - 1)); + } + + /** Spieler duerfen Monster schlagen (zurueckdraengen), aber keine anderen Spieler. */ + private void onEntityAttack(EntityAttackEvent event) { + if(!(event.getEntity() instanceof Player attacker)) return; + if(!(event.getTarget() instanceof BackroomsMonster monster)) return; + if(this.isBeforeBeginning) return; + + monster.damage(DamageType.PLAYER_ATTACK, 3); + var knockback = monster.getPosition().sub(attacker.getPosition()).asVec().withY(0).normalize().mul(8).withY(4); + monster.setVelocity(monster.getVelocity().add(knockback)); + } + + /** Hit-Delay + Abfangen toedlichen Schadens fuer die Leben-Logik. */ + private void onEntityDamage(EntityDamageEvent event) { + if(!(event.getEntity() instanceof Player player)) return; + BackroomsPlayer state = this.playerState(player); + if(state == null || state.isEliminated() || state.isEscaped()) { + event.setCancelled(true); + return; + } + + // Immunitaetsfenster wie Vanilla: ohne PvP-Lib gibt es sonst keinen Hit-Delay + long now = System.currentTimeMillis(); + if(now - state.getLastDamageMillis() < 500) { + event.setCancelled(true); + return; + } + state.setLastDamageMillis(now); + + if(event.getDamage().getAmount() < player.getHealth()) return; + event.setCancelled(true); + this.handleDeath(player, state); + } + + private void handleDeath(Player player, BackroomsPlayer state) { + state.loseLife(); + player.setHealth((float) player.getAttributeValue(Attribute.MAX_HEALTH)); + + if(state.getLives() <= 0) { + state.setEliminated(true); + player.setGameMode(GameMode.SPECTATOR); + new TitleMessage(Duration.ofSeconds(2)).appendTranslated("game_Backrooms#eliminated").send(player); + this.playSound(Sound.sound(SoundEvent.ENTITY_WITHER_SPAWN, Sound.Source.HOSTILE, 0.4f, 0.6f)); + this.getScore().insertResult(player); + this.updateSidebars(); + return; + } + + // Respawn an zufaelliger Position im aktuellen Level; nur Schluessel bleiben im Inventar + this.stripInventoryExceptKeys(player); + LevelRuntime level = this.levels.get(state.getLevelIndex()); + // Im Finale bekommt der Respawn eine frische persoenliche Jagd samt Vorsprung + if(level.getDefinition().type() == LevelType.FINALE) { + state.setFinaleSeconds(BackroomsPlayer.FINALE_NOT_STARTED); + state.setFinaleZoneX(Double.NEGATIVE_INFINITY); + } + player.teleport(level.randomSpawn()); + player.addEffect(new Potion(PotionEffect.RESISTANCE, (byte) 4, 60)); + player.addEffect(new Potion(PotionEffect.BLINDNESS, (byte) 0, 40)); + player.playSound(Sound.sound(SoundEvent.ENTITY_ELDER_GUARDIAN_CURSE, Sound.Source.HOSTILE, 1f, 0.5f), Sound.Emitter.self()); + new TitleMessage(Duration.ofSeconds(1)).appendTranslated("game_Backrooms#died").send(player); + this.updateSidebars(); + } + + private void stripInventoryExceptKeys(Player player) { + BackroomsItems.removeMatching(player, item -> !item.isAir() + && !BackroomsItems.is(item, BackroomsItems.LEVEL_KEY) + && !BackroomsItems.is(item, BackroomsItems.DOOR_COMPASS)); + } + + /** Uebergang ins naechste Level (durch Fall, Glitch-Boden oder No-Clip-Wand). */ + public void advanceLevel(Player player, boolean teleport) { + BackroomsPlayer state = this.playerState(player); + if(state == null) return; + int previous = state.getLevelIndex(); + int next = previous + 1; + if(next >= this.levels.size()) return; + state.setLevelIndex(next); + state.setLevelEnteredMillis(System.currentTimeMillis()); + + // Key und Kompass des alten Levels sind hier wertlos — weg damit + BackroomsItems.removeMatching(player, item -> BackroomsItems.isDoorItemFor(item, previous)); + + LevelRuntime level = this.levels.get(next); + // Ins Finale steigt man immer am Korridor-Anfang ein — sonst wuerde der + // Fall-Schacht mitten auf der Rennstrecke enden + boolean isFinale = level.getDefinition().type() == LevelType.FINALE; + if(teleport || isFinale) player.teleport(level.randomSpawn()); + + // Ist die Tuer dieses Levels schon offen, gibt es den Kompass zum Nachkommen + if(this.isDoorOpened(next)) this.chestManager.giveCompassIfMissing(player, next); + + // Sichtbarkeitsregeln werden nur beim Aufruf ausgewertet — fuer den neuen + // Spieler im Level muessen alle Monster ihre Viewer neu berechnen, sonst + // bleiben sie fuer ihn unsichtbar (greifen aber serverseitig an) + level.getMonsters().forEach(monster -> { + if(!monster.isRemoved()) monster.updateViewableRule(); + }); + + player.addEffect(new Potion(PotionEffect.BLINDNESS, (byte) 0, 40)); + player.playSound(Sound.sound(SoundEvent.BLOCK_PORTAL_TRAVEL, Sound.Source.AMBIENT, 0.4f, 0.5f), Sound.Emitter.self()); + new TitleMessage(Duration.ofSeconds(2)) + .appendTranslated(level.getDefinition().type().translationKey()) + .send(player); + this.updateSidebars(); + } + + /** Der Spieler hat das Finale durchquert und ist entkommen. */ + public void escape(Player player) { + BackroomsPlayer state = this.playerState(player); + if(state == null || state.isEscaped()) return; + state.setEscaped(true); + + player.setGameMode(GameMode.SPECTATOR); + new TitleMessage(Duration.ofSeconds(3)).appendTranslated("game_Backrooms#escaped").send(player); + this.playSound(Sound.sound(SoundEvent.UI_TOAST_CHALLENGE_COMPLETE, Sound.Source.MASTER, 0.8f, 1f)); + this.getScore().insertResult(player, 1); + this.updateSidebars(); + } + + private void createSidebar(Player player) { + Sidebar sidebar = new Sidebar(Component.text("Backrooms")); + sidebar.createLine(new Sidebar.ScoreboardLine("level", this.sidebarLevelLine(player), 2)); + sidebar.createLine(new Sidebar.ScoreboardLine("lives", this.sidebarLivesLine(player), 1)); + sidebar.createLine(new Sidebar.ScoreboardLine("players", this.sidebarPlayersLine(), 0)); + sidebar.addViewer(player); + this.sidebars.put(player.getUuid(), sidebar); + } + + private Component sidebarLevelLine(Player player) { + BackroomsPlayer state = this.playerState(player); + int index = state == null ? 0 : state.getLevelIndex(); + return TranslatedComponent.byId(this.levels.get(index).getDefinition().type().translationKey()).getAssembled(player); + } + + private Component sidebarLivesLine(Player player) { + BackroomsPlayer state = this.playerState(player); + return Component.text("❤ " + (state == null ? this.lives : Math.max(0, state.getLives()))); + } + + private Component sidebarPlayersLine() { + long active = this.playerStates.values().stream() + .filter(state -> !state.isEliminated() && !state.isEscaped()) + .count(); + return Component.text("☠ " + active); + } + + private void updateSidebars() { + this.getPlayers().forEach(player -> { + Sidebar sidebar = this.sidebars.get(player.getUuid()); + if(sidebar == null) return; + sidebar.updateLineContent("level", this.sidebarLevelLine(player)); + sidebar.updateLineContent("lives", this.sidebarLivesLine(player)); + sidebar.updateLineContent("players", this.sidebarPlayersLine()); + }); + } + + @Override + public Pos getSpawn() { + // Jeder Spieler bekommt einen zufaelligen, weit gestreuten Spawn in Level 0 + return this.levels.getFirst().randomSpawn(); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsGenerator.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsGenerator.java deleted file mode 100644 index ab4b649..0000000 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsGenerator.java +++ /dev/null @@ -1,87 +0,0 @@ -package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; - -import net.minestom.server.instance.block.Block; -import net.minestom.server.instance.generator.GenerationUnit; -import net.minestom.server.instance.generator.UnitModifier; - -import java.util.concurrent.ThreadLocalRandom; - -public class BackroomsGenerator { - public void generateRoom(GenerationUnit unit, int y) { - var modifier = unit.modifier(); - var start = unit.absoluteStart(); - var yPos = start.blockY() + y; - - for(int x = 0; x < unit.size().blockX(); x++) { - for(int z = 0; z < unit.size().blockZ(); z++) { - var xPos = start.blockX() + x; - var zPos = start.blockZ() + z; - - modifier.setBlock(xPos, yPos - 1, zPos, Block.BEDROCK); - modifier.setBlock(xPos, yPos + 5, zPos, Block.BEDROCK); - - modifier.setBlock(xPos, yPos, zPos, Block.LIGHT_GRAY_WOOL); - modifier.setBlock(xPos, yPos + 4, zPos, Block.SMOOTH_STONE_SLAB); - - // idk man... dont ask about this - if( - (x - 2) % 4 == 0 && ( - ((z - 2) % 8 == 0) || - ((z - 2) % 8 == 1) || - ((z - 2) % 8 == 3) || - ((z - 2) % 8 == 4) - ) - ) { - modifier.setBlock(xPos, yPos + 4, zPos, Block.SEA_LANTERN); - } - } - } - - this.generateWalls(modifier, start.blockX(), yPos + 1, start.blockZ()); - this.generateWalls(modifier, start.blockX() + 8, yPos + 1, start.blockZ()); - this.generateWalls(modifier, start.blockX(), yPos + 1, start.blockZ() + 8); - this.generateWalls(modifier, start.blockX() + 8, yPos + 1, start.blockZ() + 8); - } - - private void generateWalls(UnitModifier modifier, int xPos, int yPos, int zPos) { - this.generatePillar(modifier, xPos, yPos, zPos, Block.CHISELED_SANDSTONE); - - var random = ThreadLocalRandom.current(); - - var wall1 = random.nextInt(4) != 0; - var wall2 = random.nextInt(4) != 0; - var door1 = random.nextInt(2) != 0; - var door2 = random.nextInt(2) != 0; - - var door1pos = random.nextInt(2, 6); - var door2pos = random.nextInt(2, 6); - - if(wall1) { - for(int x = xPos; x < xPos + 8; x++) { - this.generatePillar(modifier, x, yPos, zPos, Block.SMOOTH_SANDSTONE); - } - } - - if(wall2) { - for(int z = zPos; z < zPos + 8; z++) { - this.generatePillar(modifier, xPos, yPos, z, Block.SMOOTH_SANDSTONE); - } - } - - if(door1 && wall1) { - this.generatePillar(modifier, xPos + door1pos, yPos, zPos, Block.AIR); - this.generatePillar(modifier, xPos + door1pos + 1, yPos, zPos, Block.AIR); - } - - if(door2 && wall2) { - this.generatePillar(modifier, xPos, yPos, zPos + door2pos, Block.AIR); - this.generatePillar(modifier, xPos, yPos, zPos + door2pos + 1, Block.AIR); - } - } - - private void generatePillar(UnitModifier modifier, int xPos, int yPos, int zPos, Block material) { - for(int y = yPos; y < yPos + 3; y++) { - modifier.setBlock(xPos, y, zPos, material); - } - } -} \ No newline at end of file diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsPlayer.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsPlayer.java new file mode 100644 index 0000000..8982939 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsPlayer.java @@ -0,0 +1,96 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; + +/** + * Laufzeit-Zustand eines Spielers: Leben, aktuelles Level, Sanity und Fortschritt. + */ +public class BackroomsPlayer { + private int lives; + private int levelIndex = 0; + private double sanity = 1.0; + private boolean escaped = false; + private boolean eliminated = false; + private long lastDamageMillis = 0; + private long levelEnteredMillis = System.currentTimeMillis(); + + // Persoenlicher Finale-Zustand: jeder Spieler hat seine eigene Verfolgungsjagd + public static final int FINALE_NOT_STARTED = Integer.MIN_VALUE; + private int finaleSeconds = FINALE_NOT_STARTED; + private double finaleZoneX = Double.NEGATIVE_INFINITY; + + public BackroomsPlayer(int lives) { + this.lives = lives; + } + + public int getLives() { + return this.lives; + } + + public void loseLife() { + this.lives--; + } + + public int getLevelIndex() { + return this.levelIndex; + } + + public void setLevelIndex(int levelIndex) { + this.levelIndex = levelIndex; + } + + public double getSanity() { + return this.sanity; + } + + public void setSanity(double sanity) { + this.sanity = sanity; + } + + public boolean isEscaped() { + return this.escaped; + } + + public void setEscaped(boolean escaped) { + this.escaped = escaped; + } + + public boolean isEliminated() { + return this.eliminated; + } + + public void setEliminated(boolean eliminated) { + this.eliminated = eliminated; + } + + public int getFinaleSeconds() { + return this.finaleSeconds; + } + + public void setFinaleSeconds(int finaleSeconds) { + this.finaleSeconds = finaleSeconds; + } + + public double getFinaleZoneX() { + return this.finaleZoneX; + } + + public void setFinaleZoneX(double finaleZoneX) { + this.finaleZoneX = finaleZoneX; + } + + public long getLevelEnteredMillis() { + return this.levelEnteredMillis; + } + + public void setLevelEnteredMillis(long levelEnteredMillis) { + this.levelEnteredMillis = levelEnteredMillis; + } + + public long getLastDamageMillis() { + return this.lastDamageMillis; + } + + public void setLastDamageMillis(long lastDamageMillis) { + this.lastDamageMillis = lastDamageMillis; + } + +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsScore.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsScore.java new file mode 100644 index 0000000..f5545e9 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsScore.java @@ -0,0 +1,58 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; + +import eu.mhsl.minenet.minigames.message.TranslatableMessage; +import eu.mhsl.minenet.minigames.message.type.TitleMessage; +import eu.mhsl.minenet.minigames.score.Score; +import net.minestom.server.entity.Player; + +import java.time.Duration; +import java.util.Set; + +/** + * Kombi-Wertung: Entkommene stehen in Flucht-Reihenfolge vorn; danach folgen die + * Gestorbenen, spaeterer Tod = besserer Platz. Der letzte Ueberlebende (via + * insertRemainingPlayers) landet direkt hinter den Entkommenen — gewinnt also, + * wenn niemand entkommen ist. + */ +public class BackroomsScore extends Score { + private int escapedCount = 0; + + /** Entkommen (Punkte-Pfad) */ + @Override + protected void insertResultImplementation(Set p, int points) { + this.getScores().add(this.escapedCount, p); + this.escapedCount++; + } + + /** Tod bzw. verbliebene Spieler bei Spielende */ + @Override + protected void insertResultImplementation(Set p) { + this.getScores().add(this.escapedCount, p); + } + + /** + * Wie Score.checkGameEnd, aber mit Untergrenze von einem echten Ergebnis: + * mit ignoreLastPlayers=1 und nur einem Spieler wuerde das Spiel sonst + * sofort nach dem Start enden (0 >= 1-1). + */ + @Override + protected void checkGameEnd() { + if(this.isClosed()) return; + if(!this.instance.isRunning()) return; + if(this.instance.getPlayers().isEmpty()) return; + + int required = Math.max(1, this.instance.getPlayers().size() - this.getIgnoreLastPlayers()); + if(this.resultCount() >= required) { + if(this.getIgnoreLastPlayers() > 0) { + this.insertRemainingPlayers(this.instance.getPlayers()); + } else { + this.setDone(); + } + } + } + + @Override + protected TranslatableMessage scoreMessage() { + return new TitleMessage(Duration.ofMillis(1000), Duration.ofSeconds(1)).appendTranslated("score#finish"); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsSmokeTest.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsSmokeTest.java new file mode 100644 index 0000000..95020f8 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/BackroomsSmokeTest.java @@ -0,0 +1,43 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; + +import eu.mhsl.minenet.minigames.instance.Dimension; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.minestom.server.Auth; +import net.minestom.server.MinecraftServer; + +/** + * Wegwerf-Smoke-Test (headless, ohne Client): baut ein Spiel, spawnt die Monster + * und prueft, ob sie tatsaechlich in der Instanz landen. Wird nach der Diagnose geloescht. + */ +public class BackroomsSmokeTest { + public static void main(String[] args) throws Exception { + MinecraftServer server = MinecraftServer.init(new Auth.Offline()); + Dimension.values(); + server.start("127.0.0.1", 25599); + + BackroomsGame game = new BackroomsGame(4, 3, 2, 0, false, 8); + + for(LevelRuntime level : game.getLevels()) { + System.out.println("[SMOKE] Level " + level.getIndex() + " = " + level.getDefinition().type() + + ", baseY=" + level.getBaseY() + + ", cells=" + level.getLayout().getCells() + + ", spawns=" + level.getLayout().getSpawnCells().size() + + ", chests=" + level.getLayout().getChestCells().size() + + ", exit=" + java.util.Arrays.toString(level.getLayout().getExitCell())); + level.getDefinition().populateMonsters(game, level, 2); + } + + Thread.sleep(8000); + + for(LevelRuntime level : game.getLevels()) { + long inInstance = level.getMonsters().stream().filter(m -> m.getInstance() != null).count(); + System.out.println("[SMOKE] Level " + level.getIndex() + " (" + level.getDefinition().type() + "): " + + level.getMonsters().size() + " Monster erzeugt, " + inInstance + " in der Instanz"); + level.getMonsters().forEach(m -> System.out.println("[SMOKE] - " + m.getEntityType() + + " @ " + m.getPosition() + " instance=" + (m.getInstance() != null))); + } + + System.out.println("[SMOKE] done"); + System.exit(0); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ClumpTriggerTest.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ClumpTriggerTest.java new file mode 100644 index 0000000..91db854 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ClumpTriggerTest.java @@ -0,0 +1,96 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms; + +import eu.mhsl.minenet.minigames.instance.Dimension; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Clump; +import net.minestom.server.Auth; +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.network.packet.server.SendablePacket; +import net.minestom.server.network.player.GameProfile; +import net.minestom.server.network.player.PlayerConnection; + +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.UUID; + +/** + * Wegwerf-Repro (headless): spawnt einen Clump, stellt einen Dummy-Spieler daneben + * und treibt monsterTick von Hand — prueft, ob der Jumpscare-Trigger feuert. + */ +public class ClumpTriggerTest { + public static void main(String[] args) throws Exception { + MinecraftServer server = MinecraftServer.init(new Auth.Offline()); + Dimension.values(); + server.start("127.0.0.1", 25598); + + BackroomsGame game = new BackroomsGame(4, 3, 2, 0, false, 8); + LevelRuntime level = game.getLevels().getFirst(); + + PlayerConnection connection = new PlayerConnection() { + @Override + public void sendPacket(SendablePacket packet) { + } + + @Override + public SocketAddress getRemoteAddress() { + return new InetSocketAddress("127.0.0.1", 0); + } + }; + Player player = new Player(connection, new GameProfile(UUID.randomUUID(), "Testificate")); + int[] spawnCell = level.getLayout().getSpawnCells().getFirst(); + player.setInstance(game, level.toPlayerPos(spawnCell)).join(); + player.setGameMode(GameMode.ADVENTURE); + System.out.println("[REPRO] player instance=" + (player.getInstance() != null) + " gamemode=" + player.getGameMode()); + + game.onStart(); + System.out.println("[REPRO] playerState=" + game.playerState(player)); + + Clump clump = new Clump(level) { + @Override + public void remove() { + new Exception("[REPRO] remove() aufgerufen, dead=" + this.isDead() + " health=" + this.getHealth()).printStackTrace(System.out); + super.remove(); + } + }; + try { + var position = level.toPlayerPos(spawnCell); + for(int dx = -1; dx <= 1; dx++) { + for(int dz = -1; dz <= 1; dz++) { + game.loadChunk(position.chunkX() + dx, position.chunkZ() + dz).join(); + } + } + System.out.println("[REPRO] chunks loaded, calling setInstance to " + position); + clump.setInstance(game, position).join(); + } catch(Throwable t) { + System.out.println("[REPRO] spawn failed:"); + t.printStackTrace(System.out); + } + Thread.sleep(1000); + System.out.println("[REPRO] clump instance=" + (clump.getInstance() != null) + " pos=" + clump.getPosition() + " removed=" + clump.isRemoved()); + + player.teleport(clump.getPosition().add(1.2, 0, 0)).join(); + Thread.sleep(500); + System.out.println("[REPRO] distance=" + clump.getDistance(player)); + System.out.println("[REPRO] playersInLevel(0)=" + game.playersInLevel(0).size()); + + Field stateField = BackroomsMonster.class.getDeclaredField("state"); + stateField.setAccessible(true); + for(long t = 2; t <= 160; t += 2) { + clump.monsterTick(t); + if(t % 20 == 0) { + System.out.println("[REPRO] t=" + t + " state=" + stateField.get(clump) + + " removed=" + clump.isRemoved() + + " velocity=" + clump.getVelocity() + + " playerHealth=" + player.getHealth()); + } + Thread.sleep(50); + } + + System.out.println("[REPRO] done"); + System.exit(0); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/AmbienceEngine.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/AmbienceEngine.java new file mode 100644 index 0000000..e3c4989 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/AmbienceEngine.java @@ -0,0 +1,70 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.Player; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.ExecutionType; +import net.minestom.server.timer.TaskSchedule; + +import java.util.Random; + +/** + * Spielt pro Level den Ambience-Loop und seltene positionale One-Shots, + * dazu global einen sehr leisen End-Soundtrack. Level ohne Spieler bleiben stumm. + */ +public class AmbienceEngine { + private final BackroomsGame game; + private final Random rnd = new Random(); + private int secondCounter = 0; + + public AmbienceEngine(BackroomsGame game) { + this.game = game; + } + + public void start() { + this.game.scheduler().submitTask(() -> { + if(!this.game.isRunning()) return TaskSchedule.stop(); + try { + this.tick(); + } catch(Exception e) { + // Ein Fehler darf den Ambience-Task nicht dauerhaft toeten + net.minestom.server.MinecraftServer.getExceptionManager().handleException(e); + } + return TaskSchedule.seconds(1); + }, ExecutionType.TICK_END); + } + + private void tick() { + this.secondCounter++; + + for(LevelRuntime level : this.game.getLevels()) { + var players = level.playersInLevel(); + if(players.isEmpty()) continue; + + AmbienceProfile profile = level.getDefinition().ambience(); + if(this.secondCounter % profile.loopPeriodSeconds() == 0) { + players.forEach(player -> player.playSound( + Sound.sound(profile.loop(), Sound.Source.AMBIENT, profile.loopVolume(), profile.loopPitch()), + Sound.Emitter.self() + )); + } + + for(Player player : players) { + if(this.rnd.nextDouble() >= profile.oneShotChancePerSecond()) continue; + if(profile.oneShots().isEmpty()) continue; + SoundEvent oneShot = profile.oneShots().get(this.rnd.nextInt(profile.oneShots().size())); + Jumpscare.playBehind(player, oneShot, Sound.Source.AMBIENT, 5, 0.6f, 0.8f); + } + } + + // Der End-Soundtrack als kaum hoerbare, unheimliche Untermalung + if(this.secondCounter % 180 == 1) { + this.game.getPlayers().forEach(player -> player.playSound( + Sound.sound(SoundEvent.MUSIC_END, Sound.Source.MUSIC, 0.05f, 1f), + Sound.Emitter.self() + )); + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/AmbienceProfile.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/AmbienceProfile.java new file mode 100644 index 0000000..9198767 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/AmbienceProfile.java @@ -0,0 +1,20 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience; + +import net.minestom.server.sound.SoundEvent; + +import java.util.List; + +/** + * Klangprofil eines Levels: wiederkehrender Basis-Loop plus seltene Zufalls-One-Shots + * (entfernte Schritte, Knarren, Fluestern ...), die die AmbienceEngine positional + * hinter den Spielern abspielt. + */ +public record AmbienceProfile( + SoundEvent loop, + float loopVolume, + float loopPitch, + int loopPeriodSeconds, + List oneShots, + double oneShotChancePerSecond +) { +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/Jumpscare.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/Jumpscare.java new file mode 100644 index 0000000..7ae476f --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/ambience/Jumpscare.java @@ -0,0 +1,81 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience; + +import net.kyori.adventure.sound.Sound; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.network.packet.server.play.ChangeGameStatePacket; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.TaskSchedule; + +import java.util.List; +import java.util.Random; + +/** + * Jumpscare-Rezepte. Alle Methoden sind bewusst kurz und komponierbar: + * sound() nur akustisch, visual() mit kurzem Schreckbild, fake() als Sanity-Halluzination. + */ +public final class Jumpscare { + private static final List SCARE_SOUNDS = List.of( + SoundEvent.ENTITY_ENDERMAN_SCREAM, + SoundEvent.ENTITY_CAT_HISS, + SoundEvent.ENTITY_ELDER_GUARDIAN_CURSE, + SoundEvent.AMBIENT_CAVE + ); + + private Jumpscare() { + } + + /** Positionsgebundener Sound schraeg hinter dem Spieler. */ + public static void playBehind(Player player, SoundEvent event, Sound.Source source, double distance, float volume, float pitch) { + Pos behind = player.getPosition().sub(player.getPosition().direction().mul(distance)); + player.playSound(Sound.sound(event, source, volume, pitch), behind.x(), behind.y(), behind.z()); + } + + /** Lauter Schreckenssound direkt hinter dem Spieler. */ + public static void sound(Player player, Random rnd) { + SoundEvent event = SCARE_SOUNDS.get(rnd.nextInt(SCARE_SOUNDS.size())); + playBehind(player, event, Sound.Source.HOSTILE, 3, 1.5f, 0.5f + rnd.nextFloat() * 0.2f); + } + + /** + * Schreckbild: der Elder-Guardian-Geist blitzt als Fullscreen-Overlay auf, + * gleichzeitig erscheint fuer einen Wimpernschlag ein Schaedel direkt vor dem + * Gesicht — Doppelschlag aus Overlay und physischem Schreckbild. + */ + public static void visual(Player player, Instance instance) { + player.sendPacket(new ChangeGameStatePacket(ChangeGameStatePacket.Reason.PLAYER_ELDER_GUARDIAN_MOB_APPEARANCE, 0f)); + + Vec direction = player.getPosition().direction(); + Pos position = player.getPosition() + .add(direction.mul(1.6)) + .add(0, player.getEyeHeight() - 1.4, 0) + .withYaw(player.getPosition().yaw() + 180) + .withPitch(0); + + LivingEntity scare = new LivingEntity(EntityType.ARMOR_STAND); + scare.updateViewableRule(viewer -> viewer.getUuid().equals(player.getUuid())); + scare.setInvisible(true); + scare.setNoGravity(true); + scare.setEquipment(EquipmentSlot.HELMET, ItemStack.of(Material.WITHER_SKELETON_SKULL)); + scare.setInstance(instance, position); + MinecraftServer.getSchedulerManager().scheduleTask(scare::remove, TaskSchedule.tick(8), TaskSchedule.stop()); + + player.playSound(Sound.sound(SoundEvent.ENTITY_ELDER_GUARDIAN_CURSE, Sound.Source.HOSTILE, 1.8f, 0.6f), player.getPosition()); + player.playSound(Sound.sound(SoundEvent.ENTITY_ENDERMAN_SCREAM, Sound.Source.HOSTILE, 1.2f, 0.5f), player.getPosition()); + } + + + /** Harmlose Halluzination bei niedriger Sanity: nur ein Geraeusch, keine Gefahr. */ + public static void fake(Player player, Random rnd) { + SoundEvent event = rnd.nextBoolean() ? SoundEvent.BLOCK_WOODEN_DOOR_CLOSE : SoundEvent.BLOCK_STONE_STEP; + playBehind(player, event, Sound.Source.AMBIENT, 4, 0.8f, 0.9f); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/door/DoorManager.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/door/DoorManager.java new file mode 100644 index 0000000..df49dd3 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/door/DoorManager.java @@ -0,0 +1,72 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.door; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.item.BackroomsItems; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerBlockInteractEvent; +import net.minestom.server.instance.block.Block; +import net.minestom.server.item.ItemStack; +import net.minestom.server.sound.SoundEvent; + +/** + * Verschlossene Exit-Kammern: die DOOR-Zelle ist mit IRON_BARS gefuellt und + * oeffnet sich nur mit dem Level Key des jeweiligen Levels. + */ +public class DoorManager { + private final BackroomsGame game; + + public DoorManager(BackroomsGame game) { + this.game = game; + } + + public void onInteract(PlayerBlockInteractEvent event) { + if(!event.getBlock().compare(Block.IRON_DOOR)) return; + + Player player = event.getPlayer(); + LevelRuntime level = this.game.levelAt(event.getBlockPosition().blockY()); + if(level == null) return; + + event.setCancelled(true); + event.setBlockingItemUse(true); + + ItemStack held = player.getItemInHand(event.getHand()); + if(!BackroomsItems.isKeyFor(held, level.getIndex())) { + player.playSound(Sound.sound(SoundEvent.BLOCK_CHEST_LOCKED, Sound.Source.BLOCK, 1f, 0.7f), player.getPosition()); + return; + } + + // Einzeltuer-Semantik (Finale-Duell-Kammern): Key oeffnet nur DIESE Tuer und wird verbraucht + if(level.getDefinition().opensSingleDoors()) { + player.setItemInHand(event.getHand(), held.withAmount(held.amount() - 1)); + this.openSingleDoor(level, event.getBlockPosition().blockX(), event.getBlockPosition().blockZ()); + player.playSound(Sound.sound(SoundEvent.BLOCK_IRON_DOOR_OPEN, Sound.Source.BLOCK, 1f, 0.6f), player.getPosition()); + return; + } + + this.openDoor(level); + this.game.markDoorOpened(level.getIndex()); + player.playSound(Sound.sound(SoundEvent.BLOCK_IRON_DOOR_OPEN, Sound.Source.BLOCK, 1f, 0.6f), player.getPosition()); + } + + /** Oeffnet nur die Tuer an der angeklickten Zelle. */ + private void openSingleDoor(LevelRuntime level, int blockX, int blockZ) { + LevelLayout layout = level.getLayout(); + int cx = layout.cellX(blockX); + int cz = layout.cellZ(blockZ); + if(layout.cellAt(cx, cz) != LevelLayout.Cell.DOOR) return; + int doorX = layout.worldX(cx) + 1; + int doorZ = layout.worldZ(cz) + 1; + this.game.setBlock(doorX, level.getBaseY() + 2, doorZ, Block.AIR); + this.game.setBlock(doorX, level.getBaseY() + 3, doorZ, Block.AIR); + } + + /** Oeffnet die Exit-Kammer-Tuer des Levels (Position kennt das Layout). */ + private void openDoor(LevelRuntime level) { + int[] door = level.getLayout().getDoorCell(); + if(door == null) return; + this.openSingleDoor(level, level.getLayout().worldX(door[0]) + 1, level.getLayout().worldZ(door[1]) + 1); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/generator/BackroomsChunkGenerator.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/generator/BackroomsChunkGenerator.java new file mode 100644 index 0000000..68ec824 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/generator/BackroomsChunkGenerator.java @@ -0,0 +1,335 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout.Cell; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import net.minestom.server.coordinate.Point; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.generator.GenerationUnit; +import net.minestom.server.instance.generator.Generator; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +/** + * Rendert die vorab berechneten Level-Layouts als vertikal gestapelte Ebenen. + * Reines Lookup: saemtliche Struktur (Maze, Exits, Kisten, Licht) steht im LevelLayout. + * + * Aufbau je Level (baseY b): b Bedrock, b+1 Boden, b+2..b+1+roomHeight Raum, + * b+2+roomHeight Decke, b+3+roomHeight Bedrock. Exit-Schaechte (HOLE/LADDER) durchbrechen + * Boden und die Decke des Levels darunter. + */ +public class BackroomsChunkGenerator implements Generator { + private final List levels; + /** O(1)-Lookups pro Level (statt Listen-Scans pro Blocksaeule) */ + private final List> lightKeys = new ArrayList<>(); + private final List> chestKeys = new ArrayList<>(); + + /** wiederverwendetes Random pro Generator-Thread (Seed pro Saeule gesetzt) */ + private static final ThreadLocal COLUMN_RANDOM = ThreadLocal.withInitial(Random::new); + + public BackroomsChunkGenerator(List levels) { + this.levels = levels; + for(LevelRuntime level : levels) { + Set lights = new HashSet<>(); + level.getLayout().getLightCells().forEach(cell -> lights.add(cellKey(cell[0], cell[1]))); + this.lightKeys.add(lights); + + Set chests = new HashSet<>(); + level.getLayout().getChestCells().forEach(cell -> chests.add(cellKey(cell[0], cell[1]))); + int[] keyChest = level.getLayout().getKeyChestCell(); + if(keyChest != null) chests.add(cellKey(keyChest[0], keyChest[1])); + this.chestKeys.add(chests); + } + } + + private static long cellKey(int cx, int cz) { + return ((long) cx << 32) | (cz & 0xFFFFFFFFL); + } + + @Override + public void generate(@NotNull GenerationUnit unit) { + Point start = unit.absoluteStart(); + + // Massive Zwischenschichten als ein fillHeight pro Level statt ~17 setBlock + // pro Saeule; Schacht-Saeulen werden in generateColumn wieder freigeraeumt + for(int levelIndex = 1; levelIndex < this.levels.size(); levelIndex++) { + LevelRuntime level = this.levels.get(levelIndex); + int bandStart = level.getBaseY() + 2 + level.getDefinition().palette().roomHeight() + 2; + unit.modifier().fillHeight(bandStart, level.getBaseY() + LevelRuntime.LEVEL_HEIGHT, Block.BEDROCK); + } + + for(int x = 0; x < unit.size().blockX(); x++) { + for(int z = 0; z < unit.size().blockZ(); z++) { + int worldX = start.blockX() + x; + int worldZ = start.blockZ() + z; + + for(int levelIndex = 0; levelIndex < this.levels.size(); levelIndex++) { + this.generateColumn(unit, this.levels.get(levelIndex), levelIndex, worldX, worldZ); + } + } + } + } + + private void generateColumn(GenerationUnit unit, LevelRuntime level, int levelIndex, int worldX, int worldZ) { + LevelLayout layout = level.getLayout(); + int cx = layout.cellX(worldX); + int cz = layout.cellZ(worldZ); + if(!layout.inBounds(cx, cz) || !layout.inGenerationBounds(cx, cz)) return; + + LevelPalette palette = level.getDefinition().palette(); + int baseY = level.getBaseY(); + int ceilingY = baseY + 2 + palette.roomHeight(); + Cell cell = layout.cellAt(cx, cz); + TransitionType transition = level.getDefinition().transitionType(); + + // deterministische Variation pro Saeule (fleckige Boeden/Waende) + Random rnd = COLUMN_RANDOM.get(); + rnd.setSeed(worldX * 341873128712L + worldZ * 132897987541L + levelIndex); + + boolean openShaft = cell == Cell.EXIT && (transition == TransitionType.HOLE || transition == TransitionType.LADDER); + + // Boden + Fundament + if(openShaft) { + // Schacht: kein Boden, Loch faellt bis ins Level darunter + } else if(cell == Cell.WATER) { + this.set(unit, worldX, baseY, worldZ, Block.BEDROCK); + this.set(unit, worldX, baseY + 1, worldZ, Block.WATER); + } else if(cell == Cell.DEEP_WATER) { + // doppelt tiefes Becken: zwei Wasserschichten + this.set(unit, worldX, baseY, worldZ, Block.BEDROCK); + this.set(unit, worldX, baseY + 1, worldZ, Block.WATER); + this.set(unit, worldX, baseY + 2, worldZ, Block.WATER); + } else if(cell == Cell.LEAK) { + // Undichtigkeit: Bodenpfuetze, Wassersaeule in der Zellenmitte bis zur Leitung + this.set(unit, worldX, baseY, worldZ, Block.BEDROCK); + this.set(unit, worldX, baseY + 1, worldZ, Block.WATER); + } else if(cell == Cell.EXIT && transition == TransitionType.GLITCH_FLOOR) { + this.set(unit, worldX, baseY, worldZ, Block.BEDROCK); + this.set(unit, worldX, baseY + 1, worldZ, (worldX + worldZ) % 2 == 0 ? Block.BLACK_CONCRETE : Block.PURPLE_CONCRETE); + } else if(this.isEscapeGoal(layout, cx, transition)) { + // Sichtbare Ziel-Zone am Ende des Finale-Korridors: leuchtender Boden + this.set(unit, worldX, baseY, worldZ, Block.BEDROCK); + this.set(unit, worldX, baseY + 1, worldZ, Block.GLOWSTONE); + } else { + this.set(unit, worldX, baseY, worldZ, Block.BEDROCK); + Block floor = this.isDoorStain(layout, cx, cz, rnd) + ? this.stainBlock(rnd) + : palette.floorAt(worldX, worldZ, rnd); + this.set(unit, worldX, baseY + 1, worldZ, floor); + } + + // Loot-Kisten (Barrels) in der Zellenmitte + boolean isChestColumn = !openShaft && this.isCenterColumn(worldX, worldZ) && this.isChestCell(levelIndex, cx, cz); + if(isChestColumn) { + this.set(unit, worldX, baseY + 2, worldZ, Block.BARREL); + } + + // Deko: seltene Boden-Details (Spinnweben, Kuchen, Sculk ...) und Decken-Details (Ketten, Lampen) + if(cell == Cell.FLOOR && !isChestColumn) { + if(!palette.floorDeco().isEmpty() && rnd.nextDouble() < palette.floorDecoChance()) { + this.set(unit, worldX, baseY + 2, worldZ, palette.rndFloorDeco(rnd)); + } + if(!palette.ceilingDeco().isEmpty() && rnd.nextDouble() < palette.ceilingDecoChance()) { + this.set(unit, worldX, ceilingY - 1, worldZ, palette.rndCeilingDeco(rnd)); + } + } + + // Raumvolumen + if(cell == Cell.WALL) { + // Sockelleiste unten, darueber die Wandvarianten — gibt jedem Level eine eigene Linie + boolean stained = this.isDoorStain(layout, cx, cz, rnd); + this.set(unit, worldX, baseY + 2, worldZ, stained ? this.stainBlock(rnd) : palette.wallBase()); + Block wall = palette.rndWall(rnd); + for(int y = baseY + 3; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, stained && rnd.nextInt(3) > 0 ? this.stainBlock(rnd) : wall); + } + } else if(cell == Cell.PILLAR) { + for(int y = baseY + 2; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, palette.pillar()); + } + } else if(cell == Cell.DOOR) { + // Tuerwand mit begehbarem Durchgang: entlang der Laufrichtung wird eine + // 1 Block breite Schneise durch die Zellentiefe gecarvt, mittig sitzt die Eisentuer + String facing = this.doorFacing(layout, cx, cz); + boolean passageAlongX = facing.equals("east") || facing.equals("west"); + int localX = Math.floorMod(worldX, LevelLayout.CELL_SIZE); + int localZ = Math.floorMod(worldZ, LevelLayout.CELL_SIZE); + boolean onPassage = passageAlongX ? localZ == 1 : localX == 1; + boolean isCenter = localX == 1 && localZ == 1; + + if(isCenter) { + this.set(unit, worldX, baseY + 2, worldZ, Block.IRON_DOOR.withProperties(Map.of("half", "lower", "facing", facing))); + this.set(unit, worldX, baseY + 3, worldZ, Block.IRON_DOOR.withProperties(Map.of("half", "upper", "facing", facing))); + for(int y = baseY + 4; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, palette.rndWall(rnd)); + } + } else if(onPassage) { + // Durchgang: unten frei, Tuersturz darueber + for(int y = baseY + 4; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, palette.rndWall(rnd)); + } + } else { + this.set(unit, worldX, baseY + 2, worldZ, palette.wallBase()); + Block wall = palette.rndWall(rnd); + for(int y = baseY + 3; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, wall); + } + } + } else if(cell == Cell.THIN_WALL_X || cell == Cell.THIN_WALL_Z || cell == Cell.THIN_PILLAR) { + // Prototyp-Optik: 1 Block duenne Wandebene bzw. Eckpfosten in der Zellenmitte; + // der Pfosten schlaegt Wand-Arme zu angrenzenden Segmenten, damit an den + // Rasterpunkten keine Ein-Block-Luecken bleiben + int localX = Math.floorMod(worldX, LevelLayout.CELL_SIZE); + int localZ = Math.floorMod(worldZ, LevelLayout.CELL_SIZE); + boolean isCenter = localX == 1 && localZ == 1; + boolean solid = switch(cell) { + case THIN_WALL_X -> localZ == 1; + case THIN_WALL_Z -> localX == 1; + default -> isCenter + || localZ == 1 && localX == 0 && this.joinsThinWallX(layout, cx - 1, cz) + || localZ == 1 && localX == 2 && this.joinsThinWallX(layout, cx + 1, cz) + || localX == 1 && localZ == 0 && this.joinsThinWallZ(layout, cx, cz - 1) + || localX == 1 && localZ == 2 && this.joinsThinWallZ(layout, cx, cz + 1); + }; + if(solid) { + Block block = cell == Cell.THIN_PILLAR && isCenter ? palette.pillar() : palette.rndWall(rnd); + for(int y = baseY + 2; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, block); + } + } + } else if(cell == Cell.PIPE || cell == Cell.LEAK) { + // Kupfer-Leitung unter der Decke; beim Leck laeuft eine Wassersaeule herab + Block duct = rnd.nextInt(3) == 0 ? Block.EXPOSED_COPPER : Block.WAXED_COPPER_BLOCK; + this.set(unit, worldX, ceilingY - 1, worldZ, duct); + if(cell == Cell.LEAK && this.isCenterColumn(worldX, worldZ)) { + for(int y = baseY + 2; y < ceilingY - 1; y++) { + this.set(unit, worldX, y, worldZ, Block.WATER); + } + } + } else if(cell == Cell.EXIT && transition == TransitionType.LADDER) { + // Leiter an der westlichen Schachtwand + if(Math.floorMod(worldX, LevelLayout.CELL_SIZE) == 0) { + for(int y = baseY - LevelRuntime.LEVEL_HEIGHT + 2; y < ceilingY; y++) { + this.set(unit, worldX, y, worldZ, Block.LADDER.withProperty("facing", "east")); + } + } + } + + // Decke + Abschluss; Exit-Schaechte des Levels darueber durchbrechen beides — + // dort muss auch die per fillHeight gesetzte Zwischenschicht wieder aufgehen + if(this.shaftFromAbove(levelIndex, worldX, worldZ)) { + // Roehre freiraeumen — bei Leiter-Schaechten bleibt die Leiter an der Westwand erhalten + LevelRuntime above = this.levels.get(levelIndex - 1); + boolean ladderColumn = above.getDefinition().transitionType() == TransitionType.LADDER + && Math.floorMod(worldX, LevelLayout.CELL_SIZE) == 0; + Block fill = ladderColumn ? Block.LADDER.withProperty("facing", "east") : Block.AIR; + for(int y = ceilingY; y < baseY + LevelRuntime.LEVEL_HEIGHT; y++) { + this.set(unit, worldX, y, worldZ, fill); + } + return; + } + + boolean isLight = this.isLightColumn(levelIndex, cx, cz, worldX, worldZ) + || this.isEscapeGoal(layout, cx, transition); + this.set(unit, worldX, ceilingY, worldZ, isLight ? palette.light() : palette.rndCeiling(rnd)); + this.set(unit, worldX, ceilingY + 1, worldZ, Block.BEDROCK); + } + + /** Grenzt an dieser Nachbarzelle ein Wandsegment entlang X (oder eine massive Wand) an? */ + private boolean joinsThinWallX(LevelLayout layout, int cx, int cz) { + Cell neighbour = layout.cellAt(cx, cz); + return neighbour == Cell.THIN_WALL_X || neighbour == Cell.WALL; + } + + /** Grenzt an dieser Nachbarzelle ein Wandsegment entlang Z (oder eine massive Wand) an? */ + private boolean joinsThinWallZ(LevelLayout layout, int cx, int cz) { + Cell neighbour = layout.cellAt(cx, cz); + return neighbour == Cell.THIN_WALL_Z || neighbour == Cell.WALL; + } + + /** + * "Ruined-Portal-Fleck" um die Schluesseltuer: Korruption breitet sich mit + * abnehmender Dichte ueber Boden und Waende aus und macht die Tuer weithin sichtbar. + */ + private boolean isDoorStain(LevelLayout layout, int cx, int cz, Random rnd) { + int[] door = layout.getDoorCell(); + if(door == null) return false; + int distance = Math.max(Math.abs(cx - door[0]), Math.abs(cz - door[1])); + if(distance > 3) return false; + double chance = switch(distance) { + case 0, 1 -> 0.85; + case 2 -> 0.45; + default -> 0.15; + }; + return rnd.nextDouble() < chance; + } + + private Block stainBlock(Random rnd) { + int roll = rnd.nextInt(10); + if(roll == 0) return Block.CRYING_OBSIDIAN; + if(roll == 1) return Block.GILDED_BLACKSTONE; + if(roll <= 4) return Block.OBSIDIAN; + return Block.BLACKSTONE; + } + + /** Ziel-Band des Finales: alle begehbaren Zellen ab der Exit-Zelle leuchten */ + private boolean isEscapeGoal(LevelLayout layout, int cx, TransitionType transition) { + if(transition != TransitionType.ESCAPE) return false; + int[] exit = layout.getExitCell(); + return exit != null && cx >= exit[0]; + } + + /** + * Blickrichtung der Tuer: vom Exit weg (Exit-Kammer) bzw. zur begehbaren + * Seite hin (Seitentueren im Finale-Korridor). + */ + private String doorFacing(LevelLayout layout, int cx, int cz) { + if(layout.cellAt(cx - 1, cz) == Cell.EXIT) return "east"; + if(layout.cellAt(cx + 1, cz) == Cell.EXIT) return "west"; + if(layout.cellAt(cx, cz - 1) == Cell.EXIT) return "south"; + if(layout.cellAt(cx, cz + 1) == Cell.EXIT) return "north"; + if(layout.isWalkable(cx, cz - 1) || layout.isWalkable(cx, cz + 1)) return "north"; + return "east"; + } + + /** Lichtblock in der Deckenmitte jeder Lichtzelle */ + private boolean isLightColumn(int levelIndex, int cx, int cz, int worldX, int worldZ) { + if(!this.isCenterColumn(worldX, worldZ)) return false; + return this.lightKeys.get(levelIndex).contains(cellKey(cx, cz)); + } + + private boolean isCenterColumn(int worldX, int worldZ) { + int centerOffset = LevelLayout.CELL_SIZE / 2; + return Math.floorMod(worldX - centerOffset, LevelLayout.CELL_SIZE) == 0 + && Math.floorMod(worldZ - centerOffset, LevelLayout.CELL_SIZE) == 0; + } + + private boolean isChestCell(int levelIndex, int cx, int cz) { + return this.chestKeys.get(levelIndex).contains(cellKey(cx, cz)); + } + + /** Faellt an dieser Saeule ein HOLE/LADDER-Schacht aus dem Level darueber herein? */ + private boolean shaftFromAbove(int levelIndex, int worldX, int worldZ) { + if(levelIndex == 0) return false; + LevelRuntime above = this.levels.get(levelIndex - 1); + TransitionType transition = above.getDefinition().transitionType(); + if(transition != TransitionType.HOLE && transition != TransitionType.LADDER) return false; + + LevelLayout aboveLayout = above.getLayout(); + int[] exit = aboveLayout.getExitCell(); + if(exit == null) return false; + return aboveLayout.cellX(worldX) == exit[0] && aboveLayout.cellZ(worldZ) == exit[1]; + } + + private void set(GenerationUnit unit, int x, int y, int z, Block block) { + unit.modifier().setBlock(x, y, z, block); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/generator/MazeAlgorithms.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/generator/MazeAlgorithms.java new file mode 100644 index 0000000..e91f051 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/generator/MazeAlgorithms.java @@ -0,0 +1,300 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout.Cell; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Random; + +/** + * Layout-Bausteine: Labyrinth (Recursive Backtracker), Braiding (Sackgassen oeffnen), + * Raum-Carving und BFS-Distanzkarten. Arbeitet rein auf dem Zellenraster — servertauglich + * und unit-testbar ohne Minestom. + */ +public final class MazeAlgorithms { + private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + private MazeAlgorithms() { + } + + /** + * Carvt ein perfektes Labyrinth in das (komplett mit WALL gefuellte) Layout. + * Maze-Knoten liegen auf ungeraden Zellkoordinaten; der Rand (Zelle 0 und cells-1) bleibt Wand. + */ + public static void backtracker(LevelLayout layout, Random rnd) { + int cells = layout.getCells(); + boolean[][] visited = new boolean[cells][cells]; + Deque stack = new ArrayDeque<>(); + + int[] start = {1, 1}; + visited[start[0]][start[1]] = true; + layout.setCell(start[0], start[1], Cell.FLOOR); + stack.push(start); + + while(!stack.isEmpty()) { + int[] current = stack.peek(); + List candidates = new ArrayList<>(); + for(int[] dir : DIRECTIONS) { + int nx = current[0] + dir[0] * 2; + int nz = current[1] + dir[1] * 2; + if(nx <= 0 || nz <= 0 || nx >= cells - 1 || nz >= cells - 1) continue; + if(!visited[nx][nz]) candidates.add(new int[]{nx, nz, dir[0], dir[1]}); + } + + if(candidates.isEmpty()) { + stack.pop(); + continue; + } + + int[] next = candidates.get(rnd.nextInt(candidates.size())); + visited[next[0]][next[1]] = true; + layout.setCell(current[0] + next[2], current[1] + next[3], Cell.FLOOR); + layout.setCell(next[0], next[1], Cell.FLOOR); + stack.push(new int[]{next[0], next[1]}); + } + } + + /** + * Oeffnet den angegebenen Anteil aller Sackgassen, indem eine zufaellige + * angrenzende Wand zu FLOOR wird — macht das Labyrinth schleifenreich. + */ + public static void braid(LevelLayout layout, Random rnd, double ratio) { + int cells = layout.getCells(); + List deadEnds = new ArrayList<>(); + for(int x = 1; x < cells - 1; x++) { + for(int z = 1; z < cells - 1; z++) { + if(!layout.isWalkable(x, z)) continue; + int open = 0; + for(int[] dir : DIRECTIONS) { + if(layout.isWalkable(x + dir[0], z + dir[1])) open++; + } + if(open == 1) deadEnds.add(new int[]{x, z}); + } + } + + Collections.shuffle(deadEnds, rnd); + int toOpen = (int) Math.round(deadEnds.size() * ratio); + for(int i = 0; i < toOpen; i++) { + int[] deadEnd = deadEnds.get(i); + List walls = new ArrayList<>(); + for(int[] dir : DIRECTIONS) { + int wx = deadEnd[0] + dir[0]; + int wz = deadEnd[1] + dir[1]; + int behindX = deadEnd[0] + dir[0] * 2; + int behindZ = deadEnd[1] + dir[1] * 2; + if(wx <= 0 || wz <= 0 || wx >= cells - 1 || wz >= cells - 1) continue; + if(layout.cellAt(wx, wz) == Cell.WALL && layout.isWalkable(behindX, behindZ)) { + walls.add(new int[]{wx, wz}); + } + } + if(!walls.isEmpty()) { + int[] wall = walls.get(rnd.nextInt(walls.size())); + layout.setCell(wall[0], wall[1], Cell.FLOOR); + } + } + } + + /** + * Raeumt zufaellige Rechtecke frei (Raeume). Entfernt nur Waende, die Konnektivitaet + * des Labyrinths bleibt daher erhalten. + */ + public static void carveRooms(LevelLayout layout, Random rnd, int roomCount, int minSize, int maxSize) { + int cells = layout.getCells(); + for(int i = 0; i < roomCount; i++) { + int width = minSize + rnd.nextInt(maxSize - minSize + 1); + int depth = minSize + rnd.nextInt(maxSize - minSize + 1); + int startX = 1 + rnd.nextInt(Math.max(1, cells - width - 2)); + int startZ = 1 + rnd.nextInt(Math.max(1, cells - depth - 2)); + for(int x = startX; x < startX + width; x++) { + for(int z = startZ; z < startZ + depth; z++) { + if(x >= cells - 1 || z >= cells - 1) continue; + if(layout.cellAt(x, z) == Cell.WALL) layout.setCell(x, z, Cell.FLOOR); + } + } + } + } + + /** + * BFS-Distanzen (in Zellen) von einer Startzelle aus; -1 = unerreichbar. + */ + public static int[][] bfsDistances(LevelLayout layout, int[] start) { + int cells = layout.getCells(); + int[][] distances = new int[cells][cells]; + for(int[] row : distances) Arrays.fill(row, -1); + + Deque queue = new ArrayDeque<>(); + distances[start[0]][start[1]] = 0; + queue.add(start); + while(!queue.isEmpty()) { + int[] current = queue.poll(); + for(int[] dir : DIRECTIONS) { + int nx = current[0] + dir[0]; + int nz = current[1] + dir[1]; + if(!layout.inBounds(nx, nz) || !layout.isWalkable(nx, nz)) continue; + if(distances[nx][nz] != -1) continue; + distances[nx][nz] = distances[current[0]][current[1]] + 1; + queue.add(new int[]{nx, nz}); + } + } + return distances; + } + + /** + * Alle begehbaren Zellen, deren BFS-Distanz im Perzentil-Fenster [minPercentile, maxPercentile] + * der maximalen Distanz liegt. + */ + public static List cellsInDistancePercentile(LevelLayout layout, int[][] distances, double minPercentile, double maxPercentile) { + int max = 0; + for(int[] row : distances) { + for(int distance : row) max = Math.max(max, distance); + } + + List result = new ArrayList<>(); + for(int x = 0; x < layout.getCells(); x++) { + for(int z = 0; z < layout.getCells(); z++) { + int distance = distances[x][z]; + if(distance < 0) continue; + double percentile = max == 0 ? 0 : (double) distance / max; + if(percentile >= minPercentile && percentile <= maxPercentile) result.add(new int[]{x, z}); + } + } + return result; + } + + /** + * Offene Halle: alle Innenzellen werden begehbar, nur der Rand bleibt Wand. + * Grundlage fuer Level mit "grosser Raum statt Gaenge"-Charakter (Parkhaus, Poolrooms). + */ + public static void openField(LevelLayout layout) { + int cells = layout.getCells(); + for(int x = 1; x < cells - 1; x++) { + for(int z = 1; z < cells - 1; z++) { + layout.setCell(x, z, Cell.FLOOR); + } + } + } + + /** + * Verstreut gerade Wandzuege (mit gelegentlichen Luecken und L-Knicken) in einer + * offenen Flaeche — ergibt Sichtblocker und Raumgefuehl statt Korridore. + */ + public static void scatterWallRuns(LevelLayout layout, Random rnd, int runCount, int minLength, int maxLength) { + int cells = layout.getCells(); + for(int i = 0; i < runCount; i++) { + int x = 2 + rnd.nextInt(cells - 4); + int z = 2 + rnd.nextInt(cells - 4); + int[] dir = DIRECTIONS[rnd.nextInt(DIRECTIONS.length)]; + int length = minLength + rnd.nextInt(Math.max(1, maxLength - minLength + 1)); + + for(int step = 0; step < length; step++) { + if(x <= 1 || z <= 1 || x >= cells - 2 || z >= cells - 2) break; + // gelegentliche Luecke, damit keine Sackgassen-Kaefige entstehen + if(rnd.nextInt(8) != 0) layout.setCell(x, z, Cell.WALL); + // seltener L-Knick + if(rnd.nextInt(6) == 0) dir = DIRECTIONS[rnd.nextInt(DIRECTIONS.length)]; + x += dir[0]; + z += dir[1]; + } + } + } + + /** + * Verbindet eine (moeglicherweise eingemauerte) Zelle mit der Hauptkomponente: + * BFS durch Waende bis zur ersten Zelle mit rootDistances >= 0, Pfad wird gecarvt. + */ + public static void connectToComponent(LevelLayout layout, int[][] rootDistances, int[] start) { + int cells = layout.getCells(); + Deque queue = new ArrayDeque<>(); + java.util.Map cameFrom = new java.util.HashMap<>(); + queue.add(start); + cameFrom.put(key(start[0], start[1]), null); + + while(!queue.isEmpty()) { + int[] current = queue.poll(); + for(int[] dir : DIRECTIONS) { + int nx = current[0] + dir[0]; + int nz = current[1] + dir[1]; + if(nx <= 0 || nz <= 0 || nx >= cells - 1 || nz >= cells - 1) continue; + // Exit-Kammern sind tabu: weder durch die Tuer noch durch die Kammermauern carven + if(isChamberProtected(layout, nx, nz)) continue; + long cellKey = key(nx, nz); + if(cameFrom.containsKey(cellKey)) continue; + cameFrom.put(cellKey, current); + + if(rootDistances[nx][nz] >= 0) { + // Pfad zurueckverfolgen und freiraeumen + int[] step = current; + while(step != null) { + if(!layout.isWalkable(step[0], step[1])) layout.setCell(step[0], step[1], Cell.FLOOR); + step = cameFrom.get(key(step[0], step[1])); + } + return; + } + queue.add(new int[]{nx, nz}); + } + } + } + + /** + * Stellt sicher, dass alle wichtigen Zellen von root aus erreichbar sind; + * abgetrennte Zellen werden per Wanddurchbruch angebunden. + */ + public static void ensureConnected(LevelLayout layout, int[] root, List important) { + int[][] distances = bfsDistances(layout, root); + for(int[] cell : important) { + if(!layout.inBounds(cell[0], cell[1])) continue; + if(distances[cell[0]][cell[1]] >= 0) continue; + connectToComponent(layout, distances, cell); + distances = bfsDistances(layout, root); + } + } + + private static long key(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } + + /** Zelle gehoert zur verschlossenen Exit-Kammer (Tuer, Exit oder Kammermauer-Ring). */ + public static boolean isChamberProtected(LevelLayout layout, int cx, int cz) { + LevelLayout.Cell cell = layout.cellAt(cx, cz); + if(cell == LevelLayout.Cell.DOOR || cell == LevelLayout.Cell.EXIT) return true; + for(int dx = -1; dx <= 1; dx++) { + for(int dz = -1; dz <= 1; dz++) { + if(layout.cellAt(cx + dx, cz + dz) == LevelLayout.Cell.EXIT) return true; + } + } + return false; + } + + /** + * Rejection-Sampling: bis zu count Zellen aus candidates mit paarweisem Mindestabstand. + * Faellt der Abstand nicht mehr erfuellbar aus, wird er schrittweise verringert. + */ + public static List sampleSpread(List candidates, Random rnd, int count, double minDistance) { + List pool = new ArrayList<>(candidates); + Collections.shuffle(pool, rnd); + List chosen = new ArrayList<>(); + double distance = minDistance; + while(chosen.size() < count && distance >= 0) { + for(int[] candidate : pool) { + if(chosen.size() >= count) break; + boolean fits = true; + for(int[] existing : chosen) { + double dx = candidate[0] - existing[0]; + double dz = candidate[1] - existing[1]; + if(Math.sqrt(dx * dx + dz * dz) < distance) { + fits = false; + break; + } + } + if(fits) chosen.add(candidate); + } + distance -= 1; + } + return chosen; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/item/BackroomsItems.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/item/BackroomsItems.java new file mode 100644 index 0000000..809f2d2 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/item/BackroomsItems.java @@ -0,0 +1,106 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.item; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Point; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.entity.Player; +import net.minestom.server.item.component.LodestoneTracker; +import net.minestom.server.tag.Tag; + +import java.util.function.Predicate; + +/** + * ItemStack-Fabriken und Tag-basierte Erkennung aller Backrooms-Items. + */ +public final class BackroomsItems { + public static final Tag ITEM_TAG = Tag.String("backroomsItem"); + public static final Tag KEY_LEVEL_TAG = Tag.Integer("backroomsKeyLevel"); + + public static final String ALMOND_WATER = "almond_water"; + public static final String ENERGY_BAR = "energy_bar"; + public static final String LEVEL_KEY = "level_key"; + public static final String DOOR_COMPASS = "door_compass"; + + private BackroomsItems() { + } + + /** Stellt Sanity wieder her und heilt leicht — das Kern-Loot der Backrooms. */ + public static ItemStack almondWater() { + return ItemStack.builder(Material.POTION) + .customName(Component.text("Almond Water", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false)) + .set(ITEM_TAG, ALMOND_WATER) + .build(); + } + + /** Kurzer Geschwindigkeits-Schub (auch als Strecken-Pickup im Finale). */ + public static ItemStack energyBar() { + return ItemStack.builder(Material.GOLDEN_CARROT) + .customName(Component.text("Energy Bar", NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)) + .set(ITEM_TAG, ENERGY_BAR) + .build(); + } + + /** Schluessel fuer die verschlossene Exit-Kammer des angegebenen Levels. */ + public static ItemStack levelKey(int levelIndex) { + return ItemStack.builder(Material.TRIPWIRE_HOOK) + .customName(Component.text("Level Key", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)) + .set(ITEM_TAG, LEVEL_KEY) + .set(KEY_LEVEL_TAG, levelIndex) + .build(); + } + + /** Kompass, der zur verschlossenen Tuer des Levels zeigt. */ + public static ItemStack doorCompass(int levelIndex, String dimension, Point doorPosition) { + return ItemStack.builder(Material.COMPASS) + .customName(Component.text("Door Compass", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)) + .set(DataComponents.LODESTONE_TRACKER, new LodestoneTracker(dimension, doorPosition, false)) + .set(ITEM_TAG, DOOR_COMPASS) + .set(KEY_LEVEL_TAG, levelIndex) + .build(); + } + + /** Entfernt alle Items, auf die das Praedikat passt, aus dem Spieler-Inventar. */ + public static void removeMatching(Player player, Predicate filter) { + var inventory = player.getInventory(); + for(int slot = 0; slot < inventory.getSize(); slot++) { + if(filter.test(inventory.getItemStack(slot))) inventory.setItemStack(slot, ItemStack.AIR); + } + } + + public static boolean hasMatching(Player player, Predicate filter) { + var inventory = player.getInventory(); + for(int slot = 0; slot < inventory.getSize(); slot++) { + if(filter.test(inventory.getItemStack(slot))) return true; + } + return false; + } + + public static int count(Player player, String itemId) { + int count = 0; + var inventory = player.getInventory(); + for(int slot = 0; slot < inventory.getSize(); slot++) { + ItemStack item = inventory.getItemStack(slot); + if(is(item, itemId)) count += item.amount(); + } + return count; + } + + public static boolean is(ItemStack item, String id) { + return id.equals(item.getTag(ITEM_TAG)); + } + + /** Key oder Tuerkompass fuer das angegebene Level (beides verfaellt beim Tueroeffnen). */ + public static boolean isDoorItemFor(ItemStack item, int levelIndex) { + String id = item.getTag(ITEM_TAG); + if(!LEVEL_KEY.equals(id) && !DOOR_COMPASS.equals(id)) return false; + return Integer.valueOf(levelIndex).equals(item.getTag(KEY_LEVEL_TAG)); + } + + public static boolean isKeyFor(ItemStack item, int levelIndex) { + return is(item, LEVEL_KEY) && Integer.valueOf(levelIndex).equals(item.getTag(KEY_LEVEL_TAG)); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelDefinition.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelDefinition.java new file mode 100644 index 0000000..0ad1596 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelDefinition.java @@ -0,0 +1,229 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Bauplan eines Leveltyps: Layout-Erzeugung, Blockpalette, Uebergang, Ambience und + * Monster-Besatz. Pro Spiel wird je Level eine frische Instanz erzeugt — Level-Events + * (Blackouts, Lichtausfaelle, Duelle) duerfen daher eigenen Zustand halten. + */ +public abstract class LevelDefinition { + + public abstract LevelType type(); + + public abstract LevelLayout buildLayout(Random rnd, int cells, int playerCount); + + public abstract LevelPalette palette(); + + public abstract TransitionType transitionType(); + + public abstract AmbienceProfile ambience(); + + /** Initiale Monster-Population; density 1-3 aus der Spielkonfiguration. */ + public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { + } + + /** Wird ~1x pro Sekunde aufgerufen, solange Spieler im Level sind (Events wie Blackouts). */ + public void onLevelTick(BackroomsGame game, LevelRuntime runtime) { + } + + /** + * Dynamischer Nachschub des Monster-Directors: liefert ein Monster fuer die gegebene + * Zelle in Spielernaehe — oder null, wenn dort (oder in diesem Level) nichts spawnen soll. + * Das Monster wird vom Director gespawnt und registriert. + */ + public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { + return null; + } + + /** Exit-Kammer verschlossen, Schluessel in einer Kiste versteckt? */ + public boolean usesKeyDoor() { + return false; + } + + /** + * true: jeder Key oeffnet nur die angeklickte Tuer und wird verbraucht (Finale-Seitentueren); + * false: ein Key oeffnet die Exit-Kammer fuer alle, danach zerfallen alle Keys. + */ + public boolean opensSingleDoors() { + return false; + } + + /** Permanente Dunkelheit (Lights Out): DARKNESS-Effekt unabhaengig von darkZones. */ + public boolean permanentDarkness() { + return false; + } + + /** + * Standard-Bauweise fuer Maze-Level: Backtracker, Braiding, Raeume, dann garantierte + * Platzierung von Spawns (verteilt), Exit (fern der Spawns, optional mit verschlossener + * Kammer + Schluessel), Kisten und Lichtzellen. + */ + protected LevelLayout standardLayout( + Random rnd, + int cells, + int playerCount, + double braidRatio, + int roomCount, + int maxRoomSize, + double chestsPerHundredCells + ) { + LevelLayout layout = new LevelLayout(cells); + MazeAlgorithms.backtracker(layout, rnd); + MazeAlgorithms.braid(layout, rnd, braidRatio); + MazeAlgorithms.carveRooms(layout, rnd, roomCount, 3, maxRoomSize); + return this.finalizeLayout(layout, rnd, playerCount, chestsPerHundredCells); + } + + /** + * Alternative Bauweise: offene Halle mit verstreuten Wandzuegen statt Gaenge-Labyrinth. + * Fuer Level mit "grosser offener Raum"-Charakter (Parkhaus, Poolrooms). + */ + protected LevelLayout openLayout( + Random rnd, + int cells, + int playerCount, + int wallRunCount, + double chestsPerHundredCells + ) { + LevelLayout layout = new LevelLayout(cells); + MazeAlgorithms.openField(layout); + MazeAlgorithms.scatterWallRuns(layout, rnd, wallRunCount, 3, Math.max(5, cells / 4)); + return this.finalizeLayout(layout, rnd, playerCount, chestsPerHundredCells); + } + + /** Gemeinsame Platzierung: Spawns, Exit (+Kammer), Kisten, Konnektivitaet, Licht. */ + protected LevelLayout finalizeLayout(LevelLayout layout, Random rnd, int playerCount, double chestsPerHundredCells) { + int cells = layout.getCells(); + + // Spawns: ueber die ganze Flaeche verteilt, damit auch viele Spieler nicht clustern + int[][] fromCorner = MazeAlgorithms.bfsDistances(layout, new int[]{1, 1}); + List allFloor = MazeAlgorithms.cellsInDistancePercentile(layout, fromCorner, 0, 1); + int spawnCount = Math.max(8, playerCount + 2); + layout.getSpawnCells().addAll(MazeAlgorithms.sampleSpread(allFloor, rnd, spawnCount, cells / 4.0)); + + // Exit: weit weg vom Spawn-Schwerpunkt + int[] centroid = this.spawnCentroid(layout); + int[][] fromCentroid = MazeAlgorithms.bfsDistances(layout, this.nearestWalkable(layout, centroid)); + List farCells = MazeAlgorithms.cellsInDistancePercentile(layout, fromCentroid, 0.8, 1.0); + int[] exit = farCells.isEmpty() ? allFloor.getLast() : farCells.get(rnd.nextInt(farCells.size())); + layout.setExitCell(exit); + layout.setCell(exit[0], exit[1], LevelLayout.Cell.EXIT); + + int[] doorOutside = this.usesKeyDoor() ? this.buildExitChamber(layout, rnd, fromCentroid, allFloor) : null; + + // Kisten verteilt ueber die Flaeche + int chestCount = (int) Math.max(3, cells * cells * chestsPerHundredCells / 100.0); + List chestSpots = MazeAlgorithms.sampleSpread(allFloor, rnd, chestCount, cells / 6.0); + chestSpots.removeIf(cell -> layout.cellAt(cell[0], cell[1]) != LevelLayout.Cell.FLOOR); + // Kisten nie auf Spawn-Zellen — sonst steht der Barrel im Spieler + chestSpots.removeIf(cell -> layout.getSpawnCells().stream() + .anyMatch(spawn -> spawn[0] == cell[0] && spawn[1] == cell[1])); + layout.getChestCells().addAll(chestSpots); + + // Die Kammer-Mauern (oder Pech beim Carven) koennen Bereiche abtrennen — + // alle spielrelevanten Zellen garantiert mit der Hauptkomponente verbinden + List important = new ArrayList<>(layout.getSpawnCells()); + important.addAll(layout.getChestCells()); + if(layout.getKeyChestCell() != null) important.add(layout.getKeyChestCell()); + if(doorOutside != null) important.add(doorOutside); + if(!this.usesKeyDoor()) important.add(layout.getExitCell()); + MazeAlgorithms.ensureConnected(layout, layout.getSpawnCells().getFirst(), important); + + // Lichtzellen im Raster, ausserhalb von Dunkelzonen (darkZones setzen Level-Impls vorher/nachher) + for(int x = 1; x < cells - 1; x += 2) { + for(int z = 1; z < cells - 1; z += 2) { + if(layout.isWalkable(x, z) && !layout.isDarkCell(x, z)) layout.getLightCells().add(new int[]{x, z}); + } + } + + return layout; + } + + /** + * Ummauert die Exit-Zelle (3x3) und setzt genau einen DOOR-Zugang mit garantiert + * begehbarer Zelle davor; der Schluessel landet als Kiste im freien Bereich. + * + * @return die Zelle direkt vor der Tuer (fuer die Konnektivitaetspruefung) + */ + private int[] buildExitChamber(LevelLayout layout, Random rnd, int[][] distances, List allFloor) { + int[] exit = layout.getExitCell(); + + // Tuer-Richtung waehlen, bevor der Ring zugemauert wird: bevorzugt eine Richtung, + // deren Vorfeld-Zelle (exit + 2*dir) bereits begehbar ist + int[][] directions = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + List valid = new ArrayList<>(); + List preferred = new ArrayList<>(); + for(int[] dir : directions) { + int outsideX = exit[0] + dir[0] * 2; + int outsideZ = exit[1] + dir[1] * 2; + if(outsideX <= 0 || outsideZ <= 0 || outsideX >= layout.getCells() - 1 || outsideZ >= layout.getCells() - 1) continue; + valid.add(dir); + if(layout.isWalkable(outsideX, outsideZ)) preferred.add(dir); + } + List pool = preferred.isEmpty() ? valid : preferred; + int[] doorDir = pool.get(rnd.nextInt(pool.size())); + + // Ring zumauern + for(int dx = -1; dx <= 1; dx++) { + for(int dz = -1; dz <= 1; dz++) { + if(dx == 0 && dz == 0) continue; + int x = exit[0] + dx; + int z = exit[1] + dz; + if(!layout.inBounds(x, z)) continue; + layout.setCell(x, z, LevelLayout.Cell.WALL); + } + } + + int[] door = {exit[0] + doorDir[0], exit[1] + doorDir[1]}; + int[] outside = {exit[0] + doorDir[0] * 2, exit[1] + doorDir[1] * 2}; + layout.setCell(door[0], door[1], LevelLayout.Cell.DOOR); + layout.setDoorCell(door); + if(!layout.isWalkable(outside[0], outside[1])) layout.setCell(outside[0], outside[1], LevelLayout.Cell.FLOOR); + + List keySpots = MazeAlgorithms.cellsInDistancePercentile(layout, distances, 0.3, 0.7); + keySpots.removeIf(cell -> layout.cellAt(cell[0], cell[1]) != LevelLayout.Cell.FLOOR); + // Fallback nur auf Zellen, die nach dem Kammerbau noch begehbar sind + List keyFallback = allFloor.stream() + .filter(cell -> layout.cellAt(cell[0], cell[1]) == LevelLayout.Cell.FLOOR) + .toList(); + int[] keyCell = keySpots.isEmpty() + ? keyFallback.get(rnd.nextInt(keyFallback.size())) + : keySpots.get(rnd.nextInt(keySpots.size())); + layout.setKeyChestCell(keyCell); + + return outside; + } + + private int[] spawnCentroid(LevelLayout layout) { + int sumX = 0; + int sumZ = 0; + for(int[] spawn : layout.getSpawnCells()) { + sumX += spawn[0]; + sumZ += spawn[1]; + } + int count = Math.max(1, layout.getSpawnCells().size()); + return new int[]{sumX / count, sumZ / count}; + } + + private int[] nearestWalkable(LevelLayout layout, int[] cell) { + if(layout.isWalkable(cell[0], cell[1])) return cell; + for(int radius = 1; radius < layout.getCells(); radius++) { + for(int dx = -radius; dx <= radius; dx++) { + for(int dz = -radius; dz <= radius; dz++) { + int x = cell[0] + dx; + int z = cell[1] + dz; + if(layout.inBounds(x, z) && layout.isWalkable(x, z)) return new int[]{x, z}; + } + } + } + return new int[]{1, 1}; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelLayout.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelLayout.java new file mode 100644 index 0000000..37fd786 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelLayout.java @@ -0,0 +1,187 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level; + +import net.minestom.server.coordinate.Pos; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Vorab berechnetes Layout eines Levels auf einem quadratischen Zellenraster. + * Eine Zelle ist {@link #CELL_SIZE} Bloecke breit; das Raster ist um x=0/z=0 zentriert. + * Der Chunk-Generator liest ausschliesslich aus dieser Struktur. + */ +public class LevelLayout { + public static final int CELL_SIZE = 3; + + public enum Cell { + WALL, + FLOOR, + WATER, + /** doppelt tiefes Wasserbecken */ + DEEP_WATER, + PILLAR, + /** Deckenleitung (Kupfer-Kanal unter der Decke, darunter begehbar) */ + PIPE, + /** duenne Wand (1 Block) durch die Zellenmitte entlang der X-Achse — Prototyp-Optik von Level 0 */ + THIN_WALL_X, + /** duenne Wand (1 Block) durch die Zellenmitte entlang der Z-Achse */ + THIN_WALL_Z, + /** einzelner Eckpfosten (1x1) in der Zellenmitte */ + THIN_PILLAR, + /** undichte Leitung: Wassersaeule laeuft von der Decke bis in eine Bodenpfuetze */ + LEAK, + /** Exit-Zelle: Wirkung haengt vom TransitionType des Levels ab (Loch, Leiter, Glitch, NoClip-Wand) */ + EXIT, + /** verschlossener Zugang zur Exit-Kammer, wird vom DoorManager geoeffnet */ + DOOR + } + + private final int cells; + private final Cell[][] grid; + private final boolean[][] darkZone; + + private final List spawnCells = new ArrayList<>(); + private final List chestCells = new ArrayList<>(); + private final List lightCells = new ArrayList<>(); + /** frei nutzbare Level-Marker (z. B. Dampf-Ventile in den Poolrooms) */ + private final List markerCells = new ArrayList<>(); + private int[] exitCell; + private int[] keyChestCell; + private int[] doorCell; + + // Generierungs-Bounds: ausserhalb rendert der Generator nichts (Finale nutzt + // nur einen schmalen Korridor seines quadratischen Rasters) + private int minGenCx = 0; + private int maxGenCx = Integer.MAX_VALUE; + private int minGenCz = 0; + private int maxGenCz = Integer.MAX_VALUE; + + public LevelLayout(int cells) { + this.cells = cells; + this.grid = new Cell[cells][cells]; + this.darkZone = new boolean[cells][cells]; + for(int x = 0; x < cells; x++) { + for(int z = 0; z < cells; z++) { + this.grid[x][z] = Cell.WALL; + } + } + } + + public int getCells() { + return this.cells; + } + + public boolean inBounds(int cx, int cz) { + return cx >= 0 && cx < this.cells && cz >= 0 && cz < this.cells; + } + + public Cell cellAt(int cx, int cz) { + if(!this.inBounds(cx, cz)) return Cell.WALL; + return this.grid[cx][cz]; + } + + public void setCell(int cx, int cz, Cell cell) { + this.grid[cx][cz] = cell; + } + + public boolean isWalkable(int cx, int cz) { + return switch(this.cellAt(cx, cz)) { + case FLOOR, WATER, DEEP_WATER, PIPE, LEAK, EXIT -> true; + default -> false; + }; + } + + + public boolean isDarkCell(int cx, int cz) { + return this.inBounds(cx, cz) && this.darkZone[cx][cz]; + } + + public void setDark(int cx, int cz, boolean dark) { + this.darkZone[cx][cz] = dark; + } + + /** + * Weltkoordinate (Block-Ecke) der Zelle; das Raster ist um den Ursprung zentriert. + */ + public int worldX(int cx) { + return (cx - this.cells / 2) * CELL_SIZE; + } + + public int worldZ(int cz) { + return this.worldX(cz); + } + + /** Zellkoordinate zu einer Block-Weltkoordinate */ + public int cellX(double worldX) { + return (int) Math.floor(worldX / CELL_SIZE) + this.cells / 2; + } + + public int cellZ(double worldZ) { + return this.cellX(worldZ); + } + + /** Spieler-Position in der Zellenmitte, einen Block ueber dem Boden */ + public Pos worldCenter(int[] cell, int baseY) { + return new Pos(this.worldX(cell[0]) + CELL_SIZE / 2.0, baseY + 2, this.worldZ(cell[1]) + CELL_SIZE / 2.0); + } + + public boolean isDarkAt(Pos position) { + return this.isDarkCell(this.cellX(position.x()), this.cellZ(position.z())); + } + + public List getSpawnCells() { + return this.spawnCells; + } + + public List getChestCells() { + return this.chestCells; + } + + public List getLightCells() { + return this.lightCells; + } + + public List getMarkerCells() { + return this.markerCells; + } + + public int[] getExitCell() { + return this.exitCell; + } + + public void setExitCell(int[] exitCell) { + this.exitCell = exitCell; + } + + public int[] getKeyChestCell() { + return this.keyChestCell; + } + + public int[] getDoorCell() { + return this.doorCell; + } + + public void setDoorCell(int[] doorCell) { + this.doorCell = doorCell; + } + + public void setKeyChestCell(int[] keyChestCell) { + this.keyChestCell = keyChestCell; + } + + public void setGenerationBounds(int minCx, int maxCx, int minCz, int maxCz) { + this.minGenCx = minCx; + this.maxGenCx = maxCx; + this.minGenCz = minCz; + this.maxGenCz = maxCz; + } + + public boolean inGenerationBounds(int cx, int cz) { + return cx >= this.minGenCx && cx <= this.maxGenCx && cz >= this.minGenCz && cz <= this.maxGenCz; + } + + public int[] randomSpawnCell(Random rnd) { + return this.spawnCells.get(rnd.nextInt(this.spawnCells.size())); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelPalette.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelPalette.java new file mode 100644 index 0000000..90151ad --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelPalette.java @@ -0,0 +1,56 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level; + +import net.minestom.server.instance.block.Block; + +import java.util.List; +import java.util.Random; + +/** + * Blockauswahl eines Levels. Listen erlauben leichte Variation (z. B. fleckiger Teppich); + * die Auswahl erfolgt deterministisch aus dem Layout-Random. + * + * Fuer eine deutliche visuelle Identitaet pro Leveltyp: + * - wallBase: abweichende unterste Wandschicht (Sockelleiste) + * - checkerFloor: Schachbrettmuster aus den ersten beiden floor-Eintraegen + * - floorDeco/ceilingDeco: seltene Deko-Bloecke auf dem Boden bzw. unter der Decke + */ +public record LevelPalette( + List floor, + List wall, + List ceiling, + Block light, + Block pillar, + int roomHeight, + Block wallBase, + boolean checkerFloor, + List floorDeco, + double floorDecoChance, + List ceilingDeco, + double ceilingDecoChance +) { + + public Block rndFloor(Random rnd) { + return this.floor.get(rnd.nextInt(this.floor.size())); + } + + public Block floorAt(int worldX, int worldZ, Random rnd) { + if(!this.checkerFloor) return this.rndFloor(rnd); + return this.floor.get(Math.floorMod(worldX + worldZ, 2)); + } + + public Block rndWall(Random rnd) { + return this.wall.get(rnd.nextInt(this.wall.size())); + } + + public Block rndCeiling(Random rnd) { + return this.ceiling.get(rnd.nextInt(this.ceiling.size())); + } + + public Block rndFloorDeco(Random rnd) { + return this.floorDeco.get(rnd.nextInt(this.floorDeco.size())); + } + + public Block rndCeilingDeco(Random rnd) { + return this.ceilingDeco.get(rnd.nextInt(this.ceilingDeco.size())); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelRuntime.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelRuntime.java new file mode 100644 index 0000000..a540dcc --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelRuntime.java @@ -0,0 +1,87 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Laufzeit-Zustand eines Levels innerhalb einer Runde: Layout, Hoehenlage, + * aktive Monster und Level-Events. Die Level sind in einer Instanz vertikal gestapelt. + */ +public class LevelRuntime { + public static final int LEVEL_HEIGHT = 24; + public static final int TOP_BASE_Y = 96; + + private final BackroomsGame game; + private final int index; + private final LevelDefinition definition; + private final LevelLayout layout; + private final Random rnd; + private final List monsters = new ArrayList<>(); + + private boolean blackoutActive = false; + + + public LevelRuntime(BackroomsGame game, int index, LevelDefinition definition, LevelLayout layout, Random rnd) { + this.game = game; + this.index = index; + this.definition = definition; + this.layout = layout; + this.rnd = rnd; + } + + public static int baseY(int index) { + return TOP_BASE_Y - index * LEVEL_HEIGHT; + } + + public int getBaseY() { + return baseY(this.index); + } + + public int getIndex() { + return this.index; + } + + public LevelDefinition getDefinition() { + return this.definition; + } + + public LevelLayout getLayout() { + return this.layout; + } + + public Random getRnd() { + return this.rnd; + } + + public List getMonsters() { + return this.monsters; + } + + public boolean isBlackoutActive() { + return this.blackoutActive; + } + + public void setBlackoutActive(boolean blackoutActive) { + this.blackoutActive = blackoutActive; + } + + + public List playersInLevel() { + return this.game.playersInLevel(this.index); + } + + /** Spieler-Position (Zellenmitte) fuer eine Layout-Zelle dieses Levels */ + public Pos toPlayerPos(int[] cell) { + return this.layout.worldCenter(cell, this.getBaseY()); + } + + public Pos randomSpawn() { + return this.toPlayerPos(this.layout.randomSpawnCell(this.rnd)); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelType.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelType.java new file mode 100644 index 0000000..59668f5 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/LevelType.java @@ -0,0 +1,25 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level; + +/** + * Alle implementierten Backrooms-Level. LOBBY ist immer das Startlevel, + * FINALE immer das letzte; der Pool dazwischen steht in BackroomsGame.LEVEL_POOL. + */ +public enum LevelType { + LOBBY("level_lobby"), + HABITABLE_ZONE("level_habitable"), + PIPE_DREAMS("level_pipeDreams"), + POOLROOMS("level_poolrooms"), + LIGHTS_OUT("level_lightsOut"), + FUN("level_fun"), + FINALE("level_finale"); + + private final String translationKey; + + LevelType(String translationKey) { + this.translationKey = translationKey; + } + + public String translationKey() { + return "game_Backrooms#" + this.translationKey; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/TransitionType.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/TransitionType.java new file mode 100644 index 0000000..b2df745 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/TransitionType.java @@ -0,0 +1,15 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level; + +/** + * Art des Level-Ausgangs an der Exit-Zelle. + */ +public enum TransitionType { + /** Loch im Boden: offener Schacht, der Spieler faellt physisch ins naechste Level */ + HOLE, + /** Leiter-Schacht nach unten */ + LADDER, + /** flackernder "Glitch"-Boden: Betreten teleportiert */ + GLITCH_FLOOR, + /** Finale: Ziel-Region erreichen = entkommen */ + ESCAPE +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/Cells.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/Cells.java new file mode 100644 index 0000000..4125494 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/Cells.java @@ -0,0 +1,113 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.function.BiPredicate; + +/** + * Gemeinsame Zellen-Hilfen der Level-Implementierungen: Monster-Platzierung fern + * der Spawns und Schutz reservierter Zellen (Spawns, Kisten, Schluessel-Kiste). + */ +final class Cells { + private static final int MONSTER_MIN_SPAWN_DISTANCE = 10; + + private Cells() { + } + + /** + * Zufaellige begehbare Zellen mit Mindestabstand (> 10 Zellen) zu allen Spawns; + * der Abstand wird schrittweise gesenkt, falls sich sonst zu wenige Zellen finden. + */ + static List farWalkable(LevelLayout layout, Random rnd, int count) { + return far(layout, rnd, count, (x, z) -> true); + } + + /** + * Flaechen-Faktor fuer Monsteranzahlen: grosse Karten (mehr Spieler) brauchen + * proportional mehr Monster, sonst wirken sie leer. + */ + static int areaScale(LevelLayout layout) { + return Math.max(1, layout.getCells() / 16); + } + + /** Wie {@link #farWalkable}, zusaetzlich gefiltert; liefert notfalls weniger als count Zellen. */ + static List far(LevelLayout layout, Random rnd, int count, BiPredicate filter) { + for(int minDistance = MONSTER_MIN_SPAWN_DISTANCE; ; minDistance -= 2) { + List candidates = new ArrayList<>(); + for(int x = 0; x < layout.getCells(); x++) { + for(int z = 0; z < layout.getCells(); z++) { + if(!layout.isWalkable(x, z) || !filter.test(x, z)) continue; + if(distanceToSpawns(layout, x, z) > minDistance) candidates.add(new int[]{x, z}); + } + } + if(candidates.size() >= count || minDistance <= 0) { + // Paarweiser Mindestabstand, damit die Monster nicht am selben Fleck stehen + double spread = Math.max(4, layout.getCells() / 8.0); + return MazeAlgorithms.sampleSpread(candidates, rnd, count, spread); + } + } + } + + static double distanceToSpawns(LevelLayout layout, int cx, int cz) { + double min = Double.MAX_VALUE; + for(int[] spawn : layout.getSpawnCells()) { + double dx = cx - spawn[0]; + double dz = cz - spawn[1]; + min = Math.min(min, Math.sqrt(dx * dx + dz * dz)); + } + return min; + } + + /** Zelle ist als Spawn, Kiste oder Schluessel-Kiste reserviert und darf nicht ueberbaut werden. */ + static boolean reserved(LevelLayout layout, int cx, int cz) { + int[] keyChest = layout.getKeyChestCell(); + return contains(layout.getSpawnCells(), cx, cz) + || contains(layout.getChestCells(), cx, cz) + || (keyChest != null && keyChest[0] == cx && keyChest[1] == cz); + } + + static boolean contains(List cells, int cx, int cz) { + for(int[] cell : cells) { + if(cell[0] == cx && cell[1] == cz) return true; + } + return false; + } + + static int openOrthogonal(LevelLayout layout, int cx, int cz) { + int open = 0; + if(layout.isWalkable(cx + 1, cz)) open++; + if(layout.isWalkable(cx - 1, cz)) open++; + if(layout.isWalkable(cx, cz + 1)) open++; + if(layout.isWalkable(cx, cz - 1)) open++; + return open; + } + + static void removeLight(LevelLayout layout, int cx, int cz) { + layout.getLightCells().removeIf(cell -> cell[0] == cx && cell[1] == cz); + } + + /** + * Stempelt kreisfoermige Dunkelzonen ins Layout und entfernt dort die Beleuchtung. + */ + static void darkBlobs(LevelLayout layout, Random rnd, int count, int minRadius, int maxRadius) { + int cells = layout.getCells(); + for(int i = 0; i < count; i++) { + int centerX = 3 + rnd.nextInt(Math.max(1, cells - 6)); + int centerZ = 3 + rnd.nextInt(Math.max(1, cells - 6)); + int radius = minRadius + rnd.nextInt(Math.max(1, maxRadius - minRadius + 1)); + for(int x = centerX - radius; x <= centerX + radius; x++) { + for(int z = centerZ - radius; z <= centerZ + radius; z++) { + if(!layout.inBounds(x, z)) continue; + double dx = x - centerX; + double dz = z - centerZ; + if(Math.sqrt(dx * dx + dz * dz) <= radius) layout.setDark(x, z, true); + } + } + } + layout.getLightCells().removeIf(cell -> layout.isDarkCell(cell[0], cell[1])); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/Level0Lobby.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/Level0Lobby.java new file mode 100644 index 0000000..a6a5b96 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/Level0Lobby.java @@ -0,0 +1,142 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Smiler; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.util.List; +import java.util.Random; + +/** + * Level 0 "The Lobby": endlose Raumfluchten im Stil des urspruenglichen Prototyps — + * duenne Sandsteinwaende mit Tuerluecken im Raster, heller Wollteppich, summende + * Deckenlampen, dazu dunkle Blackout-Zonen mit lauernden Smilern. + * Ausgang: Loch im Boden hinter einer verschlossenen Tuer. + */ +public class Level0Lobby extends LevelDefinition { + // Palette nach dem urspruenglichen Prototyp: heller Wollboden, Smooth Sandstone, + // Sea Lanterns im Deckenraster, gemeisselte Sandstein-Eckpfosten + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.LIGHT_GRAY_WOOL, Block.LIGHT_GRAY_WOOL, Block.LIGHT_GRAY_WOOL, Block.BROWN_WOOL), + List.of(Block.SMOOTH_SANDSTONE, Block.SMOOTH_SANDSTONE, Block.SMOOTH_SANDSTONE, Block.SANDSTONE), + List.of(Block.SMOOTH_STONE, Block.SMOOTH_STONE, Block.STONE), + Block.SEA_LANTERN, + Block.CHISELED_SANDSTONE, + 3, + Block.SMOOTH_SANDSTONE, + false, + List.of(Block.COBWEB), + 0.008, + List.of(), + 0 + ); + + /** Rasterweite des Prototyp-Wandmusters (in Zellen) */ + private static final int SEGMENT_GRID = 3; + + @Override + public LevelType type() { + return LevelType.LOBBY; + } + + /** + * Generierung nach dem urspruenglichen Prototyp: offene Flaeche, in die ein + * 4-Zellen-Raster aus duennen Sandsteinwand-Segmenten mit zufaelligen + * Tuerluecken und Eckpfosten gelegt wird — statt eines Gaenge-Labyrinths. + */ + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + LevelLayout layout = new LevelLayout(cells); + MazeAlgorithms.openField(layout); + + // Eckpfosten an den Rasterpunkten + for(int gx = SEGMENT_GRID; gx < cells - 1; gx += SEGMENT_GRID) { + for(int gz = SEGMENT_GRID; gz < cells - 1; gz += SEGMENT_GRID) { + layout.setCell(gx, gz, LevelLayout.Cell.THIN_PILLAR); + + // je Richtung: ~83% vorhanden, davon die Haelfte mit Tuerluecke + this.carveSegment(layout, rnd, gx, gz, true); + this.carveSegment(layout, rnd, gx, gz, false); + } + } + + this.finalizeLayout(layout, rnd, playerCount, 2); + + // Blackout-Zonen: dunkle Flecken, in denen die Deckenbeleuchtung fehlt + Cells.darkBlobs(layout, rnd, 2 + rnd.nextInt(3), 3, 6); + + return layout; + } + + + /** Ein Wandsegment vom Rasterpunkt aus entlang +x bzw. +z, mit optionaler Tuerluecke. */ + private void carveSegment(LevelLayout layout, Random rnd, int gx, int gz, boolean alongX) { + if(rnd.nextInt(6) == 0) return; + int cells = layout.getCells(); + int gap = rnd.nextInt(2) == 0 ? 1 + rnd.nextInt(SEGMENT_GRID - 1) : -1; + for(int step = 1; step < SEGMENT_GRID; step++) { + int x = alongX ? gx + step : gx; + int z = alongX ? gz : gz + step; + if((alongX ? x : z) >= cells - 1 || step == gap) continue; + if(layout.cellAt(x, z) == LevelLayout.Cell.FLOOR) { + layout.setCell(x, z, alongX ? LevelLayout.Cell.THIN_WALL_X : LevelLayout.Cell.THIN_WALL_Z); + } + } + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.HOLE; + } + + @Override + public boolean usesKeyDoor() { + return true; + } + + /** Smiler lauern ausschliesslich in den dunklen Blackout-Zonen. */ + @Override + public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { + LevelLayout layout = runtime.getLayout(); + for(int[] cell : Cells.far(layout, runtime.getRnd(), density * Cells.areaScale(layout), layout::isDarkCell)) { + Smiler smiler = new Smiler(runtime); + smiler.spawnAtCell(game, cell); + runtime.getMonsters().add(smiler); + } + } + + /** Nachschub: Smiler in Dunkelzonen, selten ein Hound in den hellen Fluren. */ + @Override + public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { + if(runtime.getLayout().isDarkCell(cell[0], cell[1])) return new Smiler(runtime); + return runtime.getRnd().nextInt(100) < 30 ? new Hound(runtime) : null; + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.BLOCK_BEACON_AMBIENT, + 0.15f, + 0.5f, + 8, + List.of(SoundEvent.AMBIENT_CAVE, SoundEvent.BLOCK_WOODEN_DOOR_OPEN, SoundEvent.BLOCK_STONE_STEP), + 0.015 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelFinale.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelFinale.java new file mode 100644 index 0000000..309a859 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelFinale.java @@ -0,0 +1,368 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Deathmoth; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Partygoer; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.Jumpscare; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Smiler; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Wretch; +import eu.mhsl.minenet.minigames.message.type.ActionBarMessage; +import eu.mhsl.minenet.minigames.message.type.TitleMessage; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsPlayer; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Random; + +/** + * Level ! "Run For Your Life": ein langer Krankenhauskorridor. Hinter den Spielern + * frisst sich eine toedliche Zone mit steigendem Tempo den Gang entlang — wer das + * Ende erreicht, ist entkommen und gewinnt. + */ +public class LevelFinale extends LevelDefinition { + private static final int CORRIDOR_CELLS = 67; + /** Vorsprung in Sekunden, bevor Meute und Todeszone losgelassen werden */ + private static final int HEAD_START_SECONDS = 12; + /** Wer diese Distanz vor dem Ende des Countdowns laeuft, loest seine Jagd sofort aus */ + private static final double RELEASE_DISTANCE = 12; + private static final double ZONE_BASE_SPEED = 1.2; + private static final double ZONE_ACCELERATION = 0.04; + private static final double ZONE_MAX_SPEED = 4.5; + + /** Sekunden Dunkelheit, bevor der Duell-Smiler erscheint */ + private static final int CHALLENGE_DELAY_SECONDS = 4; + + private final List challengeRooms = new ArrayList<>(); + private final Map challengeTimers = new HashMap<>(); + private final Map challengeSmilers = new HashMap<>(); + + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.WHITE_CONCRETE, Block.WHITE_CONCRETE, Block.LIGHT_GRAY_CONCRETE), + List.of(Block.WHITE_CONCRETE, Block.QUARTZ_BLOCK, Block.QUARTZ_BLOCK), + List.of(Block.LIGHT_GRAY_CONCRETE), + Block.SEA_LANTERN, + Block.QUARTZ_PILLAR, + 4, + Block.LIGHT_BLUE_CONCRETE, + false, + List.of(Block.COBWEB), + 0.02, + List.of(), + 0 + ); + + @Override + public LevelType type() { + return LevelType.FINALE; + } + + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + LevelLayout layout = new LevelLayout(CORRIDOR_CELLS); + + // Korridorbreite skaliert mit der Spielerzahl (2 Zellen = 6 Bloecke bis 8 Zellen = 24) + int widthCells = Math.clamp(2 + playerCount / 8, 2, 8); + int zStart = (CORRIDOR_CELLS - widthCells) / 2; + + for(int x = 1; x < CORRIDOR_CELLS - 1; x++) { + for(int z = zStart; z < zStart + widthCells; z++) { + layout.setCell(x, z, LevelLayout.Cell.FLOOR); + } + } + + // Hindernisse: einzelne Pfeiler-Zellen im Laufweg + for(int x = 8; x < CORRIDOR_CELLS - 6; x += 3 + rnd.nextInt(4)) { + int z = zStart + rnd.nextInt(widthCells); + layout.setCell(x, z, LevelLayout.Cell.PILLAR); + } + + // Alle Spieler starten am linken Ende + for(int z = zStart; z < zStart + widthCells; z++) { + layout.getSpawnCells().add(new int[]{2, z}); + layout.getSpawnCells().add(new int[]{3, z}); + } + + // Nur der Korridor + Kammern wird generiert — nicht die ~90 % toten WALL-Zellen des Rasters + layout.setGenerationBounds(0, CORRIDOR_CELLS - 1, zStart - 6, zStart + widthCells + 5); + + layout.setExitCell(new int[]{CORRIDOR_CELLS - 2, zStart + widthCells / 2}); + layout.setCell(CORRIDOR_CELLS - 2, zStart + widthCells / 2, LevelLayout.Cell.EXIT); + + // Pickups (Energy Bars/Almond Water) entlang der Strecke + List floorCells = new ArrayList<>(); + for(int x = 6; x < CORRIDOR_CELLS - 4; x++) { + for(int z = zStart; z < zStart + widthCells; z++) { + if(layout.cellAt(x, z) == LevelLayout.Cell.FLOOR) floorCells.add(new int[]{x, z}); + } + } + layout.getChestCells().addAll(MazeAlgorithms.sampleSpread(floorCells, rnd, 8, 6)); + + // Sparsame Beleuchtung in der Gangmitte + for(int x = 4; x < CORRIDOR_CELLS - 2; x += 5) { + layout.getLightCells().add(new int[]{x, zStart + widthCells / 2}); + } + + // Seitentueren zu dunklen Duell-Kammern: wer (mit Key) eintritt und den + // dort spawnenden Smiler toetet, entkommt ebenfalls + int doors = 3 + rnd.nextInt(3); + for(int i = 0; i < doors; i++) { + int x = 10 + i * (CORRIDOR_CELLS - 20) / doors + rnd.nextInt(4); + boolean north = rnd.nextBoolean(); + int wallZ = north ? zStart - 1 : zStart + widthCells; + int direction = north ? -1 : 1; + if(!layout.inBounds(x, wallZ + direction * 3)) continue; + if(layout.cellAt(x, wallZ) != LevelLayout.Cell.WALL) continue; + + layout.setCell(x, wallZ, LevelLayout.Cell.DOOR); + for(int dx = -1; dx <= 1; dx++) { + for(int step = 1; step <= 3; step++) { + int roomZ = wallZ + direction * step; + if(!layout.inBounds(x + dx, roomZ)) continue; + layout.setCell(x + dx, roomZ, LevelLayout.Cell.FLOOR); + layout.setDark(x + dx, roomZ, true); + } + } + // {RaumzentrumX, RaumzentrumZ, TuerzellenX, TuerzellenZ} + this.challengeRooms.add(new int[]{x, wallZ + direction * 2, x, wallZ}); + } + + return layout; + } + + /** + * Verschliesst die Kammertuer endgueltig: Tuerbloecke zurueck, Zelle wird Wand — + * der DoorManager oeffnet sie nie wieder, die Meute bleibt draussen. + */ + private void sealDoor(BackroomsGame game, LevelRuntime runtime, int[] room) { + LevelLayout layout = runtime.getLayout(); + int doorCx = room[2]; + int doorCz = room[3]; + if(layout.cellAt(doorCx, doorCz) == LevelLayout.Cell.WALL) return; + + int doorX = layout.worldX(doorCx) + 1; + int doorZ = layout.worldZ(doorCz) + 1; + game.setBlock(doorX, runtime.getBaseY() + 2, doorZ, Block.IRON_DOOR.withProperties(Map.of("half", "lower", "facing", "north"))); + game.setBlock(doorX, runtime.getBaseY() + 3, doorZ, Block.IRON_DOOR.withProperties(Map.of("half", "upper", "facing", "north"))); + layout.setCell(doorCx, doorCz, LevelLayout.Cell.WALL); + + game.playSound(Sound.sound(SoundEvent.BLOCK_IRON_DOOR_CLOSE, Sound.Source.BLOCK, 1.2f, 0.6f), + doorX, runtime.getBaseY() + 2, doorZ); + } + + /** Zentrum der Duell-Kammer, in der der Spieler gerade steht — oder null. */ + private int[] challengeRoomAt(LevelLayout layout, Player player) { + int cx = layout.cellX(player.getPosition().x()); + int cz = layout.cellZ(player.getPosition().z()); + for(int[] center : this.challengeRooms) { + if(Math.abs(cx - center[0]) <= 1 && Math.abs(cz - center[1]) <= 1) return center; + } + return null; + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.ESCAPE; + } + + /** Schaltet die 10%-Key-Chance der Strecken-Barrels frei (fuer die Duell-Tueren). */ + @Override + public boolean usesKeyDoor() { + return true; + } + + @Override + public boolean opensSingleDoors() { + return true; + } + + /** + * Jeder Spieler hat seine EIGENE Verfolgungsjagd: eigener Countdown, eigene Zone, + * eigene Meute. Freigabe entweder nach dem Vorsprungs-Countdown oder frueher, + * sobald der Spieler die ersten Meter gelaufen ist. Nachzuegler und Respawns + * starten dadurch immer fair mit eigenem Vorsprung. + */ + @Override + public void onLevelTick(BackroomsGame game, LevelRuntime runtime) { + LevelLayout layout = runtime.getLayout(); + double startX = layout.worldX(2); + + for(Player player : runtime.playersInLevel()) { + BackroomsPlayer state = game.playerState(player); + if(state == null) continue; + + // Ankunft: Sirene, Countdown beginnt + if(state.getFinaleSeconds() == BackroomsPlayer.FINALE_NOT_STARTED) { + state.setFinaleSeconds(-HEAD_START_SECONDS); + player.playSound(Sound.sound(SoundEvent.EVENT_RAID_HORN, Sound.Source.HOSTILE, 1f, 0.8f), Sound.Emitter.self()); + continue; + } + + int seconds = state.getFinaleSeconds() + 1; + state.setFinaleSeconds(seconds); + + if(seconds < 0) { + // Wer schon losgelaufen ist, loest seine Jagd sofort aus + if(player.getPosition().x() > startX + RELEASE_DISTANCE) { + seconds = 0; + state.setFinaleSeconds(0); + } else { + new ActionBarMessage() + .appendTranslated("game_Backrooms#finaleCountdown") + .appendStatic(Component.text(" " + (-seconds))) + .send(player); + player.playSound(Sound.sound(SoundEvent.BLOCK_NOTE_BLOCK_HAT, Sound.Source.AMBIENT, 1f, 1f), Sound.Emitter.self()); + continue; + } + } + + // Freigabe: "RENN!", persoenliche Meute hinter dem Spieler, Zone startet + if(seconds == 0) { + player.playSound(Sound.sound(SoundEvent.ENTITY_GENERIC_EXPLODE, Sound.Source.HOSTILE, 1.5f, 0.5f), Sound.Emitter.self()); + new TitleMessage(Duration.ofSeconds(2)).appendTranslated("game_Backrooms#finaleRun").send(player); + state.setFinaleZoneX(Math.max(layout.worldX(0), player.getPosition().x() - 14)); + this.spawnPackBehind(game, runtime, player); + continue; + } + + // In einer Duell-Kammer pausiert die persoenliche Zone — dort zaehlt nur der Smiler + if(this.tickChallenge(game, runtime, player)) continue; + + double speed = Math.min(ZONE_MAX_SPEED, ZONE_BASE_SPEED + seconds * ZONE_ACCELERATION); + state.setFinaleZoneX(state.getFinaleZoneX() + speed); + + if(seconds % 2 == 0) { + player.playSound(Sound.sound(SoundEvent.BLOCK_NOTE_BLOCK_PLING, Sound.Source.AMBIENT, 1f, 2f), Sound.Emitter.self()); + } + double distance = player.getPosition().x() - state.getFinaleZoneX(); + if(distance < 0) { + player.damage(DamageType.MOB_ATTACK, 3); + player.playSound(Sound.sound(SoundEvent.ENTITY_WARDEN_ATTACK_IMPACT, Sound.Source.HOSTILE, 1f, 0.7f), Sound.Emitter.self()); + } else if(distance < 15) { + player.playSound(Sound.sound(SoundEvent.ENTITY_WARDEN_HEARTBEAT, Sound.Source.HOSTILE, 1.2f, 0.8f), Sound.Emitter.self()); + } + } + } + + /** + * Duell-Kammer: nach kurzer Dunkelheit erscheint ein an den Spieler gebundener + * Smiler — wer ihn toetet, ist entkommen. + * + * @return true, wenn der Spieler gerade im Duell steckt (Zone pausiert dann) + */ + private boolean tickChallenge(BackroomsGame game, LevelRuntime runtime, Player player) { + // Sieg-Check unabhaengig vom Aufenthaltsort + Smiler smiler = this.challengeSmilers.get(player.getUuid()); + if(smiler != null && (smiler.isDead() || smiler.isRemoved())) { + this.challengeSmilers.remove(player.getUuid()); + game.escape(player); + return true; + } + + LevelLayout layout = runtime.getLayout(); + int[] room = this.challengeRoomAt(layout, player); + if(room == null) { + this.challengeTimers.remove(player.getUuid()); + return false; + } + + if(smiler != null) return true; + + Integer timer = this.challengeTimers.get(player.getUuid()); + if(timer == null) { + this.challengeTimers.put(player.getUuid(), CHALLENGE_DELAY_SECONDS); + // Die Tuer faellt hinter dir zu — und geht nie wieder auf + this.sealDoor(game, runtime, room); + player.playSound(Sound.sound(SoundEvent.BLOCK_SCULK_SHRIEKER_SHRIEK, Sound.Source.HOSTILE, 0.8f, 0.5f), Sound.Emitter.self()); + return true; + } + + if(timer > 1) { + this.challengeTimers.put(player.getUuid(), timer - 1); + player.playSound(Sound.sound(SoundEvent.ENTITY_WARDEN_HEARTBEAT, Sound.Source.HOSTILE, 0.8f, 0.6f), Sound.Emitter.self()); + return true; + } + + // Das Grinsen erscheint + this.challengeTimers.remove(player.getUuid()); + Smiler duelSmiler = new Smiler(runtime); + duelSmiler.setBoundPlayer(player); + duelSmiler.spawnAtCell(game, room); + runtime.getMonsters().add(duelSmiler); + this.challengeSmilers.put(player.getUuid(), duelSmiler); + Jumpscare.visual(player, game); + return true; + } + + /** Gemischte 3er-Meute wenige Zellen hinter dem Spieler. */ + private void spawnPackBehind(BackroomsGame game, LevelRuntime runtime, Player player) { + LevelLayout layout = runtime.getLayout(); + int playerCx = layout.cellX(player.getPosition().x()); + int playerCz = layout.cellZ(player.getPosition().z()); + + int spawned = 0; + List used = new ArrayList<>(); + for(int attempt = 0; attempt < 12 && spawned < 3; attempt++) { + // Wer noch am Korridoranfang steht, hat kein "Hinten" — dann spawnt die + // Meute in der ersten begehbaren Spalte direkt hinter bzw. neben ihm + int cx = Math.max(1, playerCx - 2 - runtime.getRnd().nextInt(4)); + int cz = playerCz + runtime.getRnd().nextInt(5) - 2; + if(!layout.isWalkable(cx, cz)) continue; + // jede Meute verteilt auf unterschiedliche Zellen + int finalCx = cx; + int finalCz = cz; + if(used.stream().anyMatch(cell -> cell[0] == finalCx && cell[1] == finalCz)) continue; + used.add(new int[]{cx, cz}); + + BackroomsMonster monster = switch(runtime.getRnd().nextInt(5)) { + case 0 -> new Partygoer(runtime); + case 1 -> new Wretch(runtime); + case 2 -> new Deathmoth(runtime); + default -> new Hound(runtime); + }; + monster.setRoaming(true); + monster.setRelentless(true); + monster.setBoundPlayer(player); + monster.spawnAtCell(game, new int[]{cx, cz}); + runtime.getMonsters().add(monster); + spawned++; + } + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.ENTITY_WARDEN_HEARTBEAT, + 0.6f, + 1f, + 4, + List.of(SoundEvent.ENTITY_WARDEN_NEARBY_CLOSE, SoundEvent.ENTITY_GHAST_SCREAM), + 0.05 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelFun.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelFun.java new file mode 100644 index 0000000..db4547b --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelFun.java @@ -0,0 +1,109 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Partygoer; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.util.List; +import java.util.Random; + +/** + * Level Fun "=)": eine endlose Kinderparty in grellen Farben, mit kaputter + * Partymusik aus der Deckenanlage. Die Partygoer streifen in Rudeln umher + * und wollen, dass man FUER IMMER bleibt. + */ +public class LevelFun extends LevelDefinition { + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.LIGHT_BLUE_WOOL, Block.CYAN_WOOL), + List.of(Block.ORANGE_CONCRETE, Block.ORANGE_TERRACOTTA, Block.YELLOW_CONCRETE), + List.of(Block.WHITE_CONCRETE), + Block.GLOWSTONE, + Block.MAGENTA_CONCRETE, + 4, + Block.LIME_CONCRETE, + true, + List.of(Block.CAKE), + 0.012, + List.of(), + 0 + ); + + @Override + public LevelType type() { + return LevelType.FUN; + } + + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + return this.standardLayout(rnd, cells, playerCount, 0.7, cells / 2, 7, 3); + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.GLITCH_FLOOR; + } + + @Override + public boolean usesKeyDoor() { + return true; + } + + /** Partygoer treten in Rudeln von 3-4 auf, jedes Rudel gemeinsam an einer Zelle. */ + @Override + public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { + Random rnd = runtime.getRnd(); + LevelLayout layout = runtime.getLayout(); + for(int[] cell : Cells.farWalkable(layout, rnd, density * Cells.areaScale(layout))) { + int packSize = 3 + rnd.nextInt(2); + for(int i = 0; i < packSize; i++) { + // Rudel bleibt zusammen, aber jedes Mitglied auf einer eigenen Nachbarzelle + int cx = cell[0]; + int cz = cell[1]; + for(int attempt = 0; attempt < 6; attempt++) { + int nx = cell[0] + rnd.nextInt(3) - 1; + int nz = cell[1] + rnd.nextInt(3) - 1; + if(layout.isWalkable(nx, nz)) { + cx = nx; + cz = nz; + break; + } + } + Partygoer partygoer = new Partygoer(runtime); + partygoer.spawnAtCell(game, new int[]{cx, cz}); + runtime.getMonsters().add(partygoer); + } + } + } + + /** "You can never catch a Partygoer alone" — der Nachschub kommt zu den Rudeln dazu. */ + @Override + public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { + return new Partygoer(runtime); + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.BLOCK_NOTE_BLOCK_BIT, + 0.6f, + 1.4f, + 6, + List.of(SoundEvent.ENTITY_VILLAGER_CELEBRATE, SoundEvent.ENTITY_FIREWORK_ROCKET_LAUNCH, SoundEvent.BLOCK_CAKE_ADD_CANDLE), + 0.04 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelHabitableZone.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelHabitableZone.java new file mode 100644 index 0000000..9723268 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelHabitableZone.java @@ -0,0 +1,136 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Wretch; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.util.List; +import java.util.Random; + +/** + * Level 1 "Habitable Zone": Parkhaus-/Lagerhallen-Atmosphaere aus grauem Beton. + * Grosse offene Flaechen mit regelmaessigen Stuetzpfeilern und vereinzelten + * Wasserpfuetzen. Event "The Flickering": kurze, zufaellige Komplett-Blackouts. + */ +public class LevelHabitableZone extends LevelDefinition { + private int blackoutSecondsLeft = 0; + private static final double FLICKERING_CHANCE_PER_SECOND = 1.0 / 60.0; + private static final double PUDDLE_CHANCE = 0.02; + + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.GRAY_CONCRETE, Block.STONE, Block.ANDESITE), + List.of(Block.STONE_BRICKS, Block.GRAY_CONCRETE, Block.CRACKED_STONE_BRICKS), + List.of(Block.GRAY_CONCRETE, Block.STONE), + Block.SEA_LANTERN, + Block.POLISHED_ANDESITE, + 5, + Block.POLISHED_ANDESITE, + false, + List.of(Block.COBWEB), + 0.006, + List.of(Block.IRON_CHAIN), + 0.015 + ); + + @Override + public LevelType type() { + return LevelType.HABITABLE_ZONE; + } + + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + LevelLayout layout = this.openLayout(rnd, cells, playerCount, cells / 2, 2); + + // Stuetzpfeiler im 4er-Raster, nur mitten in Freiflaechen (Zelle + alle 4 Nachbarn frei) + for(int x = 2; x < cells - 2; x += 4) { + for(int z = 2; z < cells - 2; z += 4) { + if(layout.cellAt(x, z) != LevelLayout.Cell.FLOOR) continue; + if(layout.cellAt(x + 1, z) != LevelLayout.Cell.FLOOR) continue; + if(layout.cellAt(x - 1, z) != LevelLayout.Cell.FLOOR) continue; + if(layout.cellAt(x, z + 1) != LevelLayout.Cell.FLOOR) continue; + if(layout.cellAt(x, z - 1) != LevelLayout.Cell.FLOOR) continue; + if(Cells.reserved(layout, x, z)) continue; + layout.setCell(x, z, LevelLayout.Cell.PILLAR); + Cells.removeLight(layout, x, z); + } + } + + // Vereinzelte Wasserpfuetzen auf dem Hallenboden + for(int x = 1; x < cells - 1; x++) { + for(int z = 1; z < cells - 1; z++) { + if(layout.cellAt(x, z) != LevelLayout.Cell.FLOOR) continue; + if(Cells.reserved(layout, x, z)) continue; + if(rnd.nextDouble() < PUDDLE_CHANCE) layout.setCell(x, z, LevelLayout.Cell.WATER); + } + } + + return layout; + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.LADDER; + } + + @Override + public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { + for(int[] cell : Cells.farWalkable(runtime.getLayout(), runtime.getRnd(), (1 + density) * Cells.areaScale(runtime.getLayout()))) { + Hound hound = new Hound(runtime); + hound.spawnAtCell(game, cell); + runtime.getMonsters().add(hound); + } + } + + /** Nachschub: meist Hounds, gelegentlich ein schneller Wretch. */ + @Override + public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { + return runtime.getRnd().nextInt(100) < 70 ? new Hound(runtime) : new Wretch(runtime); + } + + /** "The Flickering": zufaellig faellt fuer wenige Sekunden das komplette Licht aus. */ + @Override + public void onLevelTick(BackroomsGame game, LevelRuntime runtime) { + Random rnd = runtime.getRnd(); + if(!runtime.isBlackoutActive()) { + if(rnd.nextDouble() >= FLICKERING_CHANCE_PER_SECOND) return; + runtime.setBlackoutActive(true); + this.blackoutSecondsLeft = 5 + rnd.nextInt(4); + runtime.playersInLevel().forEach(player -> { + player.playSound(Sound.sound(SoundEvent.BLOCK_REDSTONE_TORCH_BURNOUT, Sound.Source.AMBIENT, 1f, 0.7f), Sound.Emitter.self()); + player.playSound(Sound.sound(SoundEvent.BLOCK_LEVER_CLICK, Sound.Source.AMBIENT, 0.8f, 0.5f), Sound.Emitter.self()); + }); + return; + } + + this.blackoutSecondsLeft--; + if(this.blackoutSecondsLeft <= 0) runtime.setBlackoutActive(false); + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.AMBIENT_BASALT_DELTAS_LOOP, + 0.2f, + 0.6f, + 12, + List.of(SoundEvent.BLOCK_POINTED_DRIPSTONE_DRIP_WATER, SoundEvent.BLOCK_IRON_DOOR_CLOSE), + 0.02 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelLightsOut.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelLightsOut.java new file mode 100644 index 0000000..fe687cb --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelLightsOut.java @@ -0,0 +1,110 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.util.List; +import java.util.Random; + +/** + * Level 6 "Lights Out": absolute Dunkelheit, keine einzige Lichtquelle, keine Monster — + * Level 6 ist gefaehrlich, weil NICHTS da ist. Navigation nur ueber einen periodischen + * Audio-Ping, der von der Exit-Position ausgeht. + */ +public class LevelLightsOut extends LevelDefinition { + private int pingSeconds = 0; + private static final int PING_PERIOD_SECONDS = 4; + private static final double HEARTBEAT_CHANCE = 0.25; + + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.SMOOTH_BASALT, Block.BLACK_CONCRETE, Block.SCULK), + List.of(Block.POLISHED_BLACKSTONE, Block.SMOOTH_BASALT), + List.of(Block.SMOOTH_BASALT), + Block.SEA_LANTERN, + Block.POLISHED_BLACKSTONE, + 3, + Block.SCULK, + false, + List.of(Block.SCULK_SENSOR, Block.SCULK_SENSOR, Block.SCULK_CATALYST), + 0.012, + List.of(), + 0 + ); + + @Override + public LevelType type() { + return LevelType.LIGHTS_OUT; + } + + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + LevelLayout layout = this.standardLayout(rnd, cells, playerCount, 0.25, cells / 4, 4, 1); + + // Das gesamte Level liegt in Dunkelheit; saemtliche Deckenlichter entfallen + for(int x = 0; x < cells; x++) { + for(int z = 0; z < cells; z++) { + layout.setDark(x, z, true); + } + } + layout.getLightCells().clear(); + + return layout; + } + + @Override + public boolean permanentDarkness() { + return true; + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.HOLE; + } + + /** Audio-Navigation: alle 4 s ein positionsgebundener Shriek vom Exit aus. */ + @Override + public void onLevelTick(BackroomsGame game, LevelRuntime runtime) { + this.pingSeconds++; + if(this.pingSeconds % PING_PERIOD_SECONDS != 0) return; + + Pos exit = runtime.toPlayerPos(runtime.getLayout().getExitCell()); + boolean heartbeat = runtime.getRnd().nextDouble() < HEARTBEAT_CHANCE; + for(Player player : runtime.playersInLevel()) { + player.playSound( + Sound.sound(SoundEvent.BLOCK_SCULK_SHRIEKER_SHRIEK, Sound.Source.AMBIENT, 1.5f, 1f), + exit.x(), exit.y(), exit.z() + ); + if(heartbeat) { + player.playSound(Sound.sound(SoundEvent.ENTITY_WARDEN_HEARTBEAT, Sound.Source.AMBIENT, 0.3f, 0.9f), Sound.Emitter.self()); + } + } + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.AMBIENT_CAVE, + 0.4f, + 0.7f, + 20, + List.of(SoundEvent.BLOCK_SCULK_SENSOR_CLICKING, SoundEvent.ENTITY_WARDEN_NEARBY_CLOSE), + 0.04 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelPipeDreams.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelPipeDreams.java new file mode 100644 index 0000000..ccf05ee --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelPipeDreams.java @@ -0,0 +1,206 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Clump; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Deathmoth; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Random; + +/** + * Level 2 "Pipe Dreams": enge, niedrige Industrietunnel voller Rohre und Maschinen. + * Fast reines Labyrinth mit verstreuten Maschinenbloecken. Event "Serienschaltung": + * regelmaessig faellt ein zusammenhaengendes Drittel der Deckenbeleuchtung aus. + */ +public class LevelPipeDreams extends LevelDefinition { + private static final double MACHINE_RATIO = 0.03; + + /** aktuell ausgefallenes Licht-Segment (-1 = keins) und Restdauer */ + private int failedSegment = -1; + private int eventSeconds = 0; + private static final int LIGHT_FAIL_PERIOD_SECONDS = 45; + private static final int LIGHT_FAIL_DURATION_SECONDS = 8; + + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.MUD_BRICKS, Block.PACKED_MUD), + List.of(Block.MUD_BRICKS, Block.DEEPSLATE_BRICKS, Block.BROWN_TERRACOTTA), + List.of(Block.DEEPSLATE_TILES), + Block.SHROOMLIGHT, + Block.OXIDIZED_COPPER, + 3, + Block.EXPOSED_COPPER, + false, + List.of(Block.COBWEB, Block.IRON_BARS), + 0.01, + List.of(Block.OXIDIZED_COPPER_CHAIN, Block.LANTERN.withProperty("hanging", "true")), + 0.02 + ); + + @Override + public LevelType type() { + return LevelType.PIPE_DREAMS; + } + + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + LevelLayout layout = this.standardLayout(rnd, cells, playerCount, 0.1, 2, 3, 2); + + // "Maschinen": verstreute PILLAR-Zellen, aber nur wenn sie keine Wege abschneiden + List floorCells = new ArrayList<>(); + for(int x = 1; x < cells - 1; x++) { + for(int z = 1; z < cells - 1; z++) { + if(layout.cellAt(x, z) != LevelLayout.Cell.FLOOR || Cells.reserved(layout, x, z)) continue; + // Nie vor der Schluesseltuer oder an der Exit-Kammer: die DOOR-Zelle ist nicht + // begehbar, der disconnects-Check wuerde eine Blockade dort nicht bemerken + if(MazeAlgorithms.isChamberProtected(layout, x, z)) continue; + if(this.nextToDoor(layout, x, z)) continue; + floorCells.add(new int[]{x, z}); + } + } + Collections.shuffle(floorCells, rnd); + int target = (int) Math.round(floorCells.size() * MACHINE_RATIO); + int placed = 0; + for(int[] cell : floorCells) { + if(placed >= target) break; + layout.setCell(cell[0], cell[1], LevelLayout.Cell.PILLAR); + if(this.disconnects(layout)) { + layout.setCell(cell[0], cell[1], LevelLayout.Cell.FLOOR); + continue; + } + Cells.removeLight(layout, cell[0], cell[1]); + placed++; + } + + return layout; + } + + private boolean nextToDoor(LevelLayout layout, int cx, int cz) { + return layout.cellAt(cx + 1, cz) == LevelLayout.Cell.DOOR + || layout.cellAt(cx - 1, cz) == LevelLayout.Cell.DOOR + || layout.cellAt(cx, cz + 1) == LevelLayout.Cell.DOOR + || layout.cellAt(cx, cz - 1) == LevelLayout.Cell.DOOR; + } + + /** Prueft per BFS, ob alle begehbaren Zellen (ausser der abgeschlossenen Exit-Kammer) verbunden bleiben. */ + private boolean disconnects(LevelLayout layout) { + int[][] distances = MazeAlgorithms.bfsDistances(layout, layout.getSpawnCells().getFirst()); + for(int x = 0; x < layout.getCells(); x++) { + for(int z = 0; z < layout.getCells(); z++) { + if(!layout.isWalkable(x, z)) continue; + if(layout.cellAt(x, z) == LevelLayout.Cell.EXIT) continue; + if(distances[x][z] < 0) return true; + } + } + return false; + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.GLITCH_FLOOR; + } + + @Override + public boolean usesKeyDoor() { + return true; + } + + @Override + public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { + LevelLayout layout = runtime.getLayout(); + + // Clumps lauern an Ecken (genau zwei orthogonal offene Richtungen) + List corners = Cells.far(layout, runtime.getRnd(), (2 + density) * Cells.areaScale(layout), (x, z) -> Cells.openOrthogonal(layout, x, z) == 2); + for(int[] cell : corners) { + Clump clump = new Clump(runtime); + clump.spawnAtCell(game, cell); + runtime.getMonsters().add(clump); + } + + // Hounds meiden die Clump-Zellen — sonst stapeln sich beide Populationen + for(int[] cell : Cells.far(layout, runtime.getRnd(), density * Cells.areaScale(layout), + (x, z) -> corners.stream().noneMatch(corner -> Math.abs(corner[0] - x) + Math.abs(corner[1] - z) < 4))) { + Hound hound = new Hound(runtime); + hound.spawnAtCell(game, cell); + runtime.getMonsters().add(hound); + } + } + + /** Nachschub: Clumps an Ecken, sonst Hounds. */ + @Override + public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { + LevelLayout layout = runtime.getLayout(); + if(Cells.openOrthogonal(layout, cell[0], cell[1]) == 2) return new Clump(runtime); + return runtime.getRnd().nextInt(100) < 60 ? new Hound(runtime) : new Deathmoth(runtime); + } + + /** "Serienschaltung": ca. alle 45 s faellt ein Drittel der Lichter fuer ~8 s aus. */ + @Override + public void onLevelTick(BackroomsGame game, LevelRuntime runtime) { + if(this.failedSegment < 0) { + this.eventSeconds++; + if(this.eventSeconds < LIGHT_FAIL_PERIOD_SECONDS) return; + + int segment = runtime.getRnd().nextInt(3); + this.failedSegment = segment; + this.eventSeconds = LIGHT_FAIL_DURATION_SECONDS; + this.setSegmentLights(game, runtime, segment, Block.DEEPSLATE_TILES); + runtime.playersInLevel().forEach(player -> + player.playSound(Sound.sound(SoundEvent.BLOCK_REDSTONE_TORCH_BURNOUT, Sound.Source.AMBIENT, 1f, 0.6f), Sound.Emitter.self())); + return; + } + + this.eventSeconds--; + if(this.eventSeconds > 0) return; + this.setSegmentLights(game, runtime, this.failedSegment, PALETTE.light()); + this.failedSegment = -1; + this.eventSeconds = 0; + } + + /** Setzt die Deckenbloecke eines nach x sortierten Drittels aller Lichtzellen. */ + private void setSegmentLights(BackroomsGame game, LevelRuntime runtime, int segment, Block block) { + LevelLayout layout = runtime.getLayout(); + List lights = new ArrayList<>(layout.getLightCells()); + lights.sort(Comparator.comparingInt(cell -> cell[0])); + + int from = segment * lights.size() / 3; + int to = segment == 2 ? lights.size() : (segment + 1) * lights.size() / 3; + int ceilingY = runtime.getBaseY() + 2 + PALETTE.roomHeight(); + for(int i = from; i < to; i++) { + int[] cell = lights.get(i); + game.setBlock(layout.worldX(cell[0]) + 1, ceilingY, layout.worldZ(cell[1]) + 1, block); + } + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.AMBIENT_NETHER_WASTES_LOOP, + 0.25f, + 0.5f, + 15, + List.of(SoundEvent.BLOCK_ANVIL_LAND, SoundEvent.BLOCK_LAVA_POP), + 0.02 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelPoolrooms.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelPoolrooms.java new file mode 100644 index 0000000..9dd2245 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/level/impl/LevelPoolrooms.java @@ -0,0 +1,182 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.impl; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.AmbienceProfile; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelDefinition; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelPalette; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.generator.MazeAlgorithms; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.BackroomsMonster; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.block.Block; +import net.minestom.server.network.packet.server.play.ParticlePacket; +import net.minestom.server.particle.Particle; +import net.minestom.server.sound.SoundEvent; + +import java.util.List; +import java.util.Random; + +/** + * "The Poolrooms": halb geflutete, weisse Fliesenhallen mit hohen Decken und + * grossen Wasserbecken — die Verschnaufpause zwischen den gefaehrlichen Ebenen. + * Ausgang: ein Abfluss (Loch) am Beckengrund. + */ +public class LevelPoolrooms extends LevelDefinition { + private static final LevelPalette PALETTE = new LevelPalette( + List.of(Block.QUARTZ_BLOCK, Block.WHITE_CONCRETE), + List.of(Block.QUARTZ_BLOCK, Block.WHITE_GLAZED_TERRACOTTA, Block.WHITE_CONCRETE), + List.of(Block.SMOOTH_QUARTZ), + Block.SEA_LANTERN, + Block.QUARTZ_PILLAR, + 6, + Block.WHITE_GLAZED_TERRACOTTA, + true, + List.of(), + 0, + List.of(), + 0 + ); + + @Override + public LevelType type() { + return LevelType.POOLROOMS; + } + + @Override + public LevelLayout buildLayout(Random rnd, int cells, int playerCount) { + LevelLayout layout = this.openLayout(rnd, cells, playerCount, cells / 4, 2); + + // 3-5 grosse rechteckige Wasserbecken; das erste ist doppelt tief + int pools = 3 + rnd.nextInt(3); + for(int i = 0; i < pools; i++) { + int width = 4 + rnd.nextInt(5); + int depth = 4 + rnd.nextInt(5); + int startX = 1 + rnd.nextInt(Math.max(1, cells - width - 2)); + int startZ = 1 + rnd.nextInt(Math.max(1, cells - depth - 2)); + LevelLayout.Cell poolType = i == 0 ? LevelLayout.Cell.DEEP_WATER : LevelLayout.Cell.WATER; + for(int x = startX; x < startX + width; x++) { + for(int z = startZ; z < startZ + depth; z++) { + if(layout.cellAt(x, z) != LevelLayout.Cell.FLOOR) continue; + if(Cells.reserved(layout, x, z)) continue; + layout.setCell(x, z, poolType); + } + } + } + + // Kupfer-Leitungsstraenge unter der Decke, quer durch die Hallen — mit Undichtigkeiten + int ducts = 3 + rnd.nextInt(3); + for(int i = 0; i < ducts; i++) { + boolean alongX = rnd.nextBoolean(); + int fixed = 3 + rnd.nextInt(cells - 6); + for(int j = 2; j < cells - 2; j++) { + int x = alongX ? j : fixed; + int z = alongX ? fixed : j; + if(layout.cellAt(x, z) != LevelLayout.Cell.FLOOR) continue; + if(Cells.reserved(layout, x, z)) continue; + if(MazeAlgorithms.isChamberProtected(layout, x, z)) continue; + // ~8% der Leitungszellen sind undicht — dort laeuft das Wasser heraus + layout.setCell(x, z, rnd.nextInt(100) < 8 ? LevelLayout.Cell.LEAK : LevelLayout.Cell.PIPE); + } + } + layout.getLightCells().removeIf(cell -> { + LevelLayout.Cell type = layout.cellAt(cell[0], cell[1]); + return type == LevelLayout.Cell.PIPE || type == LevelLayout.Cell.LEAK; + }); + + // Abfluss: Wasser stroemt ringfoermig auf das Exit-Loch zu — die Rutsche nach unten + int[] exit = layout.getExitCell(); + if(exit != null) { + for(int dx = -1; dx <= 1; dx++) { + for(int dz = -1; dz <= 1; dz++) { + if(dx == 0 && dz == 0) continue; + if(layout.cellAt(exit[0] + dx, exit[1] + dz) != LevelLayout.Cell.FLOOR) continue; + layout.setCell(exit[0] + dx, exit[1] + dz, LevelLayout.Cell.WATER); + } + } + } + + // Dampf-Ventile: an zufaelligen Leitungs- und Beckenraendern zischt es + for(int i = 0; i < cells / 5; i++) { + int x = 2 + rnd.nextInt(cells - 4); + int z = 2 + rnd.nextInt(cells - 4); + LevelLayout.Cell type = layout.cellAt(x, z); + if(type == LevelLayout.Cell.PIPE || type == LevelLayout.Cell.LEAK || type == LevelLayout.Cell.WATER) { + layout.getMarkerCells().add(new int[]{x, z}); + } + } + + // 1-2 kleine dunkle Ecken + Cells.darkBlobs(layout, rnd, 1 + rnd.nextInt(2), 2, 3); + + return layout; + } + + @Override + public LevelPalette palette() { + return PALETTE; + } + + @Override + public TransitionType transitionType() { + return TransitionType.HOLE; + } + + /** Dampf-Ventile: Wolkenpartikel + Zischen an den Markern in Spielernaehe. */ + @Override + public void onLevelTick(BackroomsGame game, LevelRuntime runtime) { + LevelLayout layout = runtime.getLayout(); + List players = runtime.playersInLevel(); + for(int[] vent : layout.getMarkerCells()) { + Pos ventPos = runtime.toPlayerPos(vent); + for(Player player : players) { + if(player.getPosition().distance(ventPos) > 28) continue; + player.sendPacket(new ParticlePacket( + Particle.CLOUD, + ventPos.x(), ventPos.y() + 2.5, ventPos.z(), + 0.3f, 0.6f, 0.3f, 0.02f, 6 + )); + if(runtime.getRnd().nextInt(100) < 12) { + player.playSound( + Sound.sound(SoundEvent.BLOCK_LAVA_EXTINGUISH, Sound.Source.AMBIENT, 0.4f, 1.6f), + ventPos.x(), ventPos.y(), ventPos.z() + ); + } + } + } + } + + /** Die Poolrooms sind bewusst fast leer — erst bei maximaler Dichte streift ein Hound umher. */ + @Override + public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { + if(density < 3) return; + for(int[] cell : Cells.farWalkable(runtime.getLayout(), runtime.getRnd(), 1)) { + Hound hound = new Hound(runtime); + hound.spawnAtCell(game, cell); + runtime.getMonsters().add(hound); + } + } + + /** Die Poolrooms bleiben ueberwiegend friedlich — nur selten verirrt sich ein Hound hierher. */ + @Override + public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { + return runtime.getRnd().nextInt(100) < 20 ? new Hound(runtime) : null; + } + + @Override + public AmbienceProfile ambience() { + return new AmbienceProfile( + SoundEvent.AMBIENT_UNDERWATER_LOOP, + 0.3f, + 1f, + 10, + List.of(SoundEvent.BLOCK_POINTED_DRIPSTONE_DRIP_WATER, SoundEvent.ENTITY_DOLPHIN_AMBIENT_WATER), + 0.03 + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/loot/ChestManager.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/loot/ChestManager.java new file mode 100644 index 0000000..0b8c14c --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/loot/ChestManager.java @@ -0,0 +1,199 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.loot; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.Jumpscare; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.item.BackroomsItems; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.BlockVec; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.network.packet.server.play.BlockChangePacket; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerBlockInteractEvent; +import net.minestom.server.instance.block.Block; +import net.minestom.server.item.ItemStack; +import net.minestom.server.sound.SoundEvent; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Loot-Kisten (Barrels): + * - Normale Kisten sind einmal pluenderbar und verschwinden. Der Loot haengt vom + * Inventar ab — wer schon viel Almond Water traegt, findet kaum noch welches; + * wer generell voll ist, findet hoechstens noch den Schluessel. + * - In Key-Door-Leveln kann JEDE Kiste mit etwas Glueck den Level Key enthalten + * (solange die Tuer zu ist und der Spieler noch keinen hat); die dedizierte + * Schluessel-Kiste gibt ihn jedem Spieler garantiert einmal. + */ +public class ChestManager { + private static final int RANDOM_KEY_CHANCE_PERCENT = 10; + private static final int RANDOM_COMPASS_CHANCE_PERCENT = 10; + + private final BackroomsGame game; + private final Map chestLevels = new HashMap<>(); + private final Map keyChestLevels = new HashMap<>(); + private final Map> keyChestLooters = new HashMap<>(); + private final Map> chestLooters = new HashMap<>(); + + public ChestManager(BackroomsGame game) { + this.game = game; + + for(LevelRuntime level : game.getLevels()) { + int chestY = level.getBaseY() + 2; + level.getLayout().getChestCells().forEach(cell -> + this.chestLevels.put(this.blockPos(level, cell, chestY), level.getIndex()) + ); + if(level.getLayout().getKeyChestCell() != null) { + this.keyChestLevels.put(this.blockPos(level, level.getLayout().getKeyChestCell(), chestY), level.getIndex()); + } + } + } + + private Point blockPos(LevelRuntime level, int[] cell, int y) { + Pos center = level.toPlayerPos(cell); + return new Pos(center.blockX(), y, center.blockZ()); + } + + public void onInteract(PlayerBlockInteractEvent event) { + if(!event.getBlock().compare(Block.BARREL)) return; + Point position = new Pos(event.getBlockPosition().blockX(), event.getBlockPosition().blockY(), event.getBlockPosition().blockZ()); + Player player = event.getPlayer(); + + Integer keyLevel = this.keyChestLevels.get(position); + if(keyLevel != null && !this.game.isDoorOpened(keyLevel)) { + event.setCancelled(true); + event.setBlockingItemUse(true); + Set looters = this.keyChestLooters.computeIfAbsent(position, unused -> new HashSet<>()); + if(!looters.add(player.getUuid())) { + player.playSound(Sound.sound(SoundEvent.BLOCK_CHEST_LOCKED, Sound.Source.BLOCK, 0.8f, 0.9f), position.x(), position.y(), position.z()); + return; + } + this.giveKey(player, keyLevel); + player.playSound(Sound.sound(SoundEvent.BLOCK_BARREL_OPEN, Sound.Source.BLOCK, 1f, 0.8f), position.x(), position.y(), position.z()); + this.hideChestFor(player, position); + return; + } + + Integer chestLevel = keyLevel != null ? keyLevel : this.chestLevels.get(position); + if(chestLevel == null) return; + + event.setCancelled(true); + event.setBlockingItemUse(true); + + // Kisten sind per Spieler pluenderbar — niemand nimmt anderen den Loot weg + Set looters = this.chestLooters.computeIfAbsent(position, unused -> new HashSet<>()); + if(!looters.add(player.getUuid())) { + // Falls der Chunk neu gesendet wurde und der Barrel wieder aufgetaucht ist: erneut ausblenden + this.hideChestFor(player, position); + return; + } + + List loot = this.rollFor(player, chestLevel); + loot.forEach(item -> player.getInventory().addItemStack(item)); + + SoundEvent sound = loot.isEmpty() ? SoundEvent.BLOCK_BARREL_CLOSE : SoundEvent.BLOCK_BARREL_OPEN; + player.playSound(Sound.sound(sound, Sound.Source.BLOCK, 1f, 0.8f), position.x(), position.y(), position.z()); + this.hideChestFor(player, position); + + // Kisten sind ein guter Jumpscare-Anker: kleine Chance auf einen Sound-Scare + if(this.game.rnd().nextInt(100) < 20) { + Jumpscare.sound(player, this.game.rnd()); + } + } + + /** + * Blendet die gepluenderte Kiste NUR fuer diesen Spieler aus — der Serverblock + * bleibt, alle anderen sehen den Barrel weiter. + */ + private void hideChestFor(Player player, Point position) { + player.sendPacket(new BlockChangePacket(new BlockVec(position.blockX(), position.blockY(), position.blockZ()), Block.AIR)); + } + + /** + * Inventarabhaengiger Loot. In Key-Door-Leveln sind Key (10 %) und Tuerkompass (10 %) + * getrennt voneinander findbar — beides maximal einmal pro Spieler. + */ + private List rollFor(Player player, int levelIndex) { + List result = new ArrayList<>(); + + LevelRuntime level = this.game.getLevels().get(levelIndex); + boolean doorRelevant = level.getDefinition().usesKeyDoor() && !this.game.isDoorOpened(levelIndex); + if(doorRelevant && !BackroomsItems.hasMatching(player, item -> BackroomsItems.isKeyFor(item, levelIndex)) && this.game.rnd().nextInt(100) < RANDOM_KEY_CHANCE_PERCENT) { + result.add(BackroomsItems.levelKey(levelIndex)); + } + if(doorRelevant && !this.hasCompassFor(player, levelIndex) && this.game.rnd().nextInt(100) < RANDOM_COMPASS_CHANCE_PERCENT) { + ItemStack compass = this.createCompass(levelIndex); + if(compass != null) result.add(compass); + } + + int almond = BackroomsItems.count(player, BackroomsItems.ALMOND_WATER); + int energy = BackroomsItems.count(player, BackroomsItems.ENERGY_BAR); + int carried = almond + energy; + + // Randvolles Inventar: die Backrooms geben dir nichts mehr — hoechstens Key/Kompass + if(carried >= 16) return result; + + LootTable table = new LootTable(); + int almondWeight = Math.max(0, 5 - almond); + int energyWeight = Math.max(1, 10 - energy); + if(almondWeight > 0) table.add(BackroomsItems::almondWater, almondWeight); + table.add(BackroomsItems::energyBar, energyWeight); + table.add(() -> ItemStack.AIR, 4); + result.addAll(table.roll(this.game.rnd())); + + return result; + } + + private void giveKey(Player player, int levelIndex) { + player.getInventory().addItemStack(BackroomsItems.levelKey(levelIndex)); + player.getInventory().addItemStack(BackroomsItems.almondWater()); + } + + private ItemStack createCompass(int levelIndex) { + Point doorPosition = this.doorPositionOf(levelIndex); + if(doorPosition == null) return null; + return BackroomsItems.doorCompass(levelIndex, this.game.getDimensionName(), doorPosition); + } + + /** Nach dem Tueroeffnen: alle Nachzuegler bekommen automatisch einen Kompass zur offenen Tuer. */ + public void giveCompassIfMissing(Player player, int levelIndex) { + if(this.hasCompassFor(player, levelIndex)) return; + ItemStack compass = this.createCompass(levelIndex); + if(compass != null) player.getInventory().addItemStack(compass); + } + + + private Point doorPositionOf(int levelIndex) { + LevelRuntime level = this.game.getLevels().get(levelIndex); + LevelLayout layout = level.getLayout(); + int[] door = layout.getDoorCell(); + if(door != null) { + return new Pos(layout.worldX(door[0]) + 1, level.getBaseY() + 2, layout.worldZ(door[1]) + 1); + } + // Fallback (Finale-Seitentueren setzen kein doorCell): erste DOOR-Zelle suchen + for(int cx = 0; cx < layout.getCells(); cx++) { + for(int cz = 0; cz < layout.getCells(); cz++) { + if(layout.cellAt(cx, cz) == LevelLayout.Cell.DOOR) { + return new Pos(layout.worldX(cx) + 1, level.getBaseY() + 2, layout.worldZ(cz) + 1); + } + } + } + return null; + } + + private boolean hasCompassFor(Player player, int levelIndex) { + return BackroomsItems.hasMatching(player, item -> + BackroomsItems.is(item, BackroomsItems.DOOR_COMPASS) + && Integer.valueOf(levelIndex).equals(item.getTag(BackroomsItems.KEY_LEVEL_TAG))); + } + + +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/loot/LootTable.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/loot/LootTable.java new file mode 100644 index 0000000..a7bc894 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/loot/LootTable.java @@ -0,0 +1,43 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.loot; + +import net.minestom.server.item.ItemStack; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.function.Supplier; + +/** + * Gewichtete Loot-Tabelle; eine Kiste liefert 1-2 Wuerfe. + */ +public class LootTable { + private record Entry(Supplier item, int weight) { + } + + private final List entries = new ArrayList<>(); + private int totalWeight = 0; + + public LootTable add(Supplier item, int weight) { + this.entries.add(new Entry(item, weight)); + this.totalWeight += weight; + return this; + } + + public List roll(Random rnd) { + List result = new ArrayList<>(); + int rolls = 1 + rnd.nextInt(2); + for(int i = 0; i < rolls; i++) { + int roll = rnd.nextInt(this.totalWeight); + for(Entry entry : this.entries) { + roll -= entry.weight(); + if(roll < 0) { + ItemStack item = entry.item().get(); + if(!item.isAir()) result.add(item); + break; + } + } + } + return result; + } + +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/BackroomsMonster.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/BackroomsMonster.java new file mode 100644 index 0000000..712bc6a --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/BackroomsMonster.java @@ -0,0 +1,198 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.sound.SoundEvent; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.Optional; + +/** + * Basisklasse aller Backrooms-Monster: einfache Zustandsmaschine, Line-of-Sight- und + * Blickrichtungs-Helfer, Navigator-Pathing und Nahkampfangriff. Der zentrale AI-Tick + * des Spiels ruft {@link #monsterTick(long)} nur auf, solange Spieler im Level sind. + */ +public abstract class BackroomsMonster extends EntityCreature { + public enum State {IDLE, ALERT, CHASE, ATTACK, RETREAT} + + protected final LevelRuntime level; + protected State state = State.IDLE; + protected long stateSince = 0; + protected Player target; + + protected double viewRange = 16; + protected float attackDamage = 4; + protected double attackRange = 1.8; + protected int attackCooldownTicks = 20; + private long lastAttackTick = 0; + + /** Vom Monster-Director in Spielernaehe gespawnt — darf bei Distanz wieder despawnen */ + private boolean roaming = false; + + /** + * Finale-Modus: unbegrenzte Verfolgungsreichweite und Gummiband-Tempo — + * je weiter das Ziel voraus ist, desto schneller wird das Monster. + */ + private boolean relentless = false; + + /** + * Fest gebundenes Ziel (persoenliche Finale-Meute): das Monster jagt NUR diesen + * Spieler und entfernt sich selbst, sobald er entkommt, stirbt oder geht. + */ + private Player boundPlayer; + + protected BackroomsMonster(EntityType entityType, LevelRuntime level) { + super(entityType); + this.level = level; + } + + public abstract void monsterTick(long tick); + + + public boolean isRoaming() { + return this.roaming; + } + + public void setRoaming(boolean roaming) { + this.roaming = roaming; + } + + public void setRelentless(boolean relentless) { + this.relentless = relentless; + } + + public void setBoundPlayer(Player boundPlayer) { + this.boundPlayer = boundPlayer; + } + + + protected void setState(State state, long tick) { + if(this.state == state) return; + this.state = state; + this.stateSince = tick; + } + + public void spawnAtCell(Instance instance, int[] cell) { + Pos position = this.level.toPlayerPos(cell); + // Entity-Tracking ist 2D — ohne diese Regel wuerden Spieler die Monster + // aller vertikal gestapelten Level mitgesendet bekommen + this.updateViewableRule(viewer -> + Math.abs(viewer.getPosition().y() - this.getPosition().y()) < LevelRuntime.LEVEL_HEIGHT + ); + // Chunks sind beim Spielstart meist noch nicht geladen — die 3x3-Nachbarschaft + // vorladen, damit Physik und Pathfinding des Monsters nicht ins Leere greifen + int chunkX = position.chunkX(); + int chunkZ = position.chunkZ(); + List> loads = new ArrayList<>(); + for(int dx = -1; dx <= 1; dx++) { + for(int dz = -1; dz <= 1; dz++) { + loads.add(instance.loadChunk(chunkX + dx, chunkZ + dz)); + } + } + CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)) + .thenRun(() -> this.setInstance(instance, position)) + .exceptionally(throwable -> { + MinecraftServer.getExceptionManager().handleException(throwable); + return null; + }); + } + + /** Monster, die selbst in der Dunkelheit leben, verlieren dort keine Sichtweite */ + protected boolean ignoresDarkness = false; + + /** Sichtbarkeit inkl. Distanz; in Dunkelzonen sehen Monster nur halb so weit. */ + protected boolean canSee(Player player) { + if(player.getGameMode() != GameMode.ADVENTURE) return false; + // Relentless-Verfolger verlieren ihr Ziel nie — egal wie weit es flieht + if(this.relentless) return true; + boolean darkPenalty = !this.ignoresDarkness && this.level.getLayout().isDarkAt(player.getPosition()); + double range = darkPenalty ? this.viewRange / 2 : this.viewRange; + if(this.getDistance(player) > range) return false; + return this.hasLineOfSight(player); + } + + protected Optional nearestVisiblePlayer() { + // Gebundene Monster kennen nur ihr eigenes Ziel + if(this.boundPlayer != null) { + boolean valid = !this.boundPlayer.isRemoved() + && this.boundPlayer.getGameMode() == GameMode.ADVENTURE + && this.level.playersInLevel().contains(this.boundPlayer); + if(!valid) { + this.remove(); + return Optional.empty(); + } + return Optional.of(this.boundPlayer).filter(this::canSee); + } + + return this.level.playersInLevel().stream() + .filter(this::canSee) + .min(Comparator.comparingDouble(this::getDistance)); + } + + /** Schaut der Spieler dieses Monster direkt an? */ + protected boolean playerLooksAtMe(Player player) { + Pos playerPos = player.getPosition(); + var toMonster = this.getPosition().add(0, 1, 0).sub(playerPos.add(0, player.getEyeHeight(), 0)).asVec().normalize(); + return playerPos.direction().dot(toMonster) > 0.92 && player.hasLineOfSight(this); + } + + protected void moveTo(Point point, double speed) { + // Gummiband im Relentless-Modus: deutlich schneller, wenn das Ziel weit voraus ist + if(this.relentless) { + double distance = this.getPosition().distance(point); + speed += Math.clamp((distance - 20) * 0.008, 0, 0.18); + } + // Die Geschwindigkeit kommt ausschliesslich aus dem MOVEMENT_SPEED-Attribut — + // der double-Parameter von setPathTo ist die Zieldistanz, NICHT die Geschwindigkeit + this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(speed); + this.getNavigator().setPathTo(point); + } + + /** Zufaellige begehbare Nachbarzelle als Wanderziel. */ + protected void wander(double speed) { + LevelLayout layout = this.level.getLayout(); + int cx = layout.cellX(this.getPosition().x()); + int cz = layout.cellZ(this.getPosition().z()); + for(int attempt = 0; attempt < 6; attempt++) { + int nx = cx + this.level.getRnd().nextInt(7) - 3; + int nz = cz + this.level.getRnd().nextInt(7) - 3; + if(!layout.isWalkable(nx, nz)) continue; + this.moveTo(this.level.toPlayerPos(new int[]{nx, nz}), speed); + return; + } + } + + /** Nahkampfangriff mit Cooldown; true, wenn zugeschlagen wurde. */ + protected boolean tryAttack(Player player, long tick) { + if(tick - this.lastAttackTick < this.attackCooldownTicks) return false; + if(this.getDistance(player) > this.attackRange) return false; + this.lastAttackTick = tick; + player.damage(DamageType.MOB_ATTACK, this.attackDamage); + var knockback = player.getPosition().sub(this.getPosition()).asVec().withY(0).normalize().mul(6).withY(4); + player.setVelocity(player.getVelocity().add(knockback)); + return true; + } + + /** Positionsgebundener Sound an alle Spieler des Levels. */ + protected void emitSound(SoundEvent sound, float volume, float pitch) { + Pos position = this.getPosition(); + this.level.playersInLevel().forEach(player -> + player.playSound(Sound.sound(sound, Sound.Source.HOSTILE, volume, pitch), position.x(), position.y(), position.z()) + ); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Clump.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Clump.java new file mode 100644 index 0000000..141c040 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Clump.java @@ -0,0 +1,59 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.metadata.cube.SlimeMeta; +import net.minestom.server.sound.SoundEvent; + +import java.util.Comparator; +import java.util.Optional; + +/** + * Clump: getarnte Proximity-Falle. Wartet regungslos, springt einen Spieler an, + * der zu nah kommt, und verschwindet 3 Sekunden nach dem Jumpscare. + */ +public class Clump extends BackroomsMonster { + private static final double TRIGGER_RANGE = 2.5; + private static final int DESPAWN_DELAY_TICKS = 60; + + public Clump(LevelRuntime level) { + super(EntityType.SLIME, level); + this.attackDamage = 4; + this.attackRange = 3; + if(this.getEntityMeta() instanceof SlimeMeta slimeMeta) { + slimeMeta.setSize(2); + } else { + this.getAttribute(Attribute.SCALE).setBaseValue(0.7); + } + } + + @Override + public void monsterTick(long tick) { + switch(this.state) { + case IDLE -> this.nearestPlayerInRange().ifPresent(player -> this.jumpscare(player, tick)); + case ATTACK -> { + if(this.target != null && !this.target.isRemoved()) this.tryAttack(this.target, tick); + if(tick - this.stateSince >= DESPAWN_DELAY_TICKS) this.remove(); + } + default -> this.setState(State.IDLE, tick); + } + } + + private Optional nearestPlayerInRange() { + return this.level.playersInLevel().stream() + .filter(player -> this.getDistance(player) < TRIGGER_RANGE) + .min(Comparator.comparingDouble(this::getDistance)); + } + + private void jumpscare(Player player, long tick) { + this.target = player; + this.emitSound(SoundEvent.ENTITY_SLIME_ATTACK, 1.5f, 0.8f); + this.emitSound(SoundEvent.ENTITY_ENDERMAN_SCREAM, 1.2f, 0.7f); + var leap = player.getPosition().sub(this.getPosition()).asVec().withY(0).normalize().mul(8).withY(6); + this.setVelocity(this.getVelocity().add(leap)); + this.tryAttack(player, tick); + this.setState(State.ATTACK, tick); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Deathmoth.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Deathmoth.java new file mode 100644 index 0000000..772116b --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Deathmoth.java @@ -0,0 +1,62 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.sound.SoundEvent; + +/** + * Deathmoth: kleiner fliegender Schwarmjaeger (Phantom-Modell). Umkreist die Beute + * flatternd und stuerzt sich in kurzen Attacken auf sie — wenig Schaden, viel Unruhe. + */ +public class Deathmoth extends BackroomsMonster { + private int swoopCooldown = 0; + + public Deathmoth(LevelRuntime level) { + super(EntityType.PHANTOM, level); + this.viewRange = 26; + this.attackDamage = 2; + this.attackRange = 2.0; + this.attackCooldownTicks = 30; + this.getAttribute(Attribute.SCALE).setBaseValue(0.6); + this.setNoGravity(true); + } + + @Override + public void monsterTick(long tick) { + if(this.target == null || this.target.isRemoved() || !this.canSee(this.target)) { + this.target = this.nearestVisiblePlayer().orElse(null); + } + + if(this.target == null) { + // Flatterndes Schweben auf der Stelle + if(tick % 20 < 2) { + this.setVelocity(new Vec( + (this.level.getRnd().nextDouble() - 0.5) * 3, + (this.level.getRnd().nextDouble() - 0.3) * 1.5, + (this.level.getRnd().nextDouble() - 0.5) * 3 + )); + } + return; + } + + Player player = this.target; + if(tick % 10 < 2) { + // Zielpunkt: leicht ueber dem Kopf der Beute, Sturzflug wenn Cooldown vorbei + this.swoopCooldown = Math.max(0, this.swoopCooldown - 10); + double height = this.swoopCooldown > 0 ? 2.5 : 0.5; + Vec toPlayer = player.getPosition().add(0, height, 0).sub(this.getPosition()).asVec(); + if(toPlayer.length() > 0.5) { + this.setVelocity(toPlayer.normalize().mul(this.swoopCooldown > 0 ? 3.5 : 6)); + } + if(tick % 40 < 2) this.emitSound(SoundEvent.ENTITY_PHANTOM_FLAP, 0.8f, 1.4f); + } + + if(this.tryAttack(player, tick)) { + this.swoopCooldown = 60; + this.emitSound(SoundEvent.ENTITY_PHANTOM_BITE, 1f, 1.3f); + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Hound.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Hound.java new file mode 100644 index 0000000..48fb492 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Hound.java @@ -0,0 +1,64 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.sound.SoundEvent; + +/** + * Hound: patrouilliert das Level, knurrt bei Sichtkontakt und jagt den Spieler, + * bis er ihn 200 Ticks lang nicht mehr sieht. + */ +public class Hound extends BackroomsMonster { + private long lastSeenTick = 0; + + public Hound(LevelRuntime level) { + super(EntityType.ZOMBIE, level); + this.viewRange = 28; + this.attackDamage = 3; + this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.22); + this.getAttribute(Attribute.SCALE).setBaseValue(1.15); + this.setEquipment(EquipmentSlot.HELMET, MonsterAppearance.playerHead(MonsterAppearance.HOUND_HEAD)); + } + + @Override + public void monsterTick(long tick) { + switch(this.state) { + case IDLE -> { + if(tick % 40 < 2) this.wander(0.1); + this.nearestVisiblePlayer().ifPresent(player -> { + this.target = player; + this.lastSeenTick = tick; + this.setState(State.ALERT, tick); + this.getNavigator().setPathTo(null); + this.emitSound(SoundEvent.ENTITY_WOLF_GROWL, 1.2f, 0.6f); + player.playSound(Sound.sound(SoundEvent.ENTITY_WARDEN_HEARTBEAT, Sound.Source.HOSTILE, 0.4f, 1f)); + }); + } + case ALERT -> { + if(tick - this.stateSince >= 40) this.setState(State.CHASE, tick); + } + case CHASE, ATTACK -> { + if(this.target == null || this.target.isRemoved() || !this.level.playersInLevel().contains(this.target)) { + this.target = null; + this.setState(State.RETREAT, tick); + return; + } + if(this.canSee(this.target)) this.lastSeenTick = tick; + if(tick - this.lastSeenTick > 100) { + this.target = null; + this.setState(State.RETREAT, tick); + return; + } + if(tick % 10 < 2) this.moveTo(this.target.getPosition(), 0.25); + this.tryAttack(this.target, tick); + } + case RETREAT -> { + if(tick % 40 < 2) this.wander(0.1); + if(tick - this.stateSince >= 100) this.setState(State.IDLE, tick); + } + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/MonsterAppearance.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/MonsterAppearance.java new file mode 100644 index 0000000..d8209d9 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/MonsterAppearance.java @@ -0,0 +1,41 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import net.minestom.server.color.Color; +import net.minestom.server.component.DataComponents; +import net.minestom.server.entity.PlayerSkin; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.network.player.ResolvableProfile; + +/** + * Statische Helfer fuer das Aussehen der Backrooms-Monster: Custom Heads aus + * Base64-Texturen und gefaerbte Lederruestung. + */ +public final class MonsterAppearance { + /** Hound (Backrooms) — duesterer Hundekopf. */ + public static final String HOUND_HEAD = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDgzMzhiMGIxNDkyMDU5YzIzMGNjYzg5ZmNhZTE0ZThhODU1ZTcwYjllNWVhNDVjYWIzZjVmNWI5ODJkYzc4MiJ9fX0="; + /** Smiler — weisses Grinsen auf schwarzem Grund. */ + public static final String SMILER_HEAD = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYWEyMDcyYzkxNDhmNjc2N2ZlYzBlMzNiNWRiYTAyN2EyOGM0NDcyMmRlODUzYTJkNGRjMDAxN2UzMmJkMDE1OCJ9fX0="; + /** Partygoer — gelber Smiley. */ + public static final String PARTYGOER_HEAD = + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDI1MzUwODhkNDQ1ZDZhNDQ5ODc2YWRhOTg4ZmExMDc5ZDU3MmQyMTQ3MTNiYjNkY2VhMWRhM2JjNGY3ZDk2YiJ9fX0="; + + private MonsterAppearance() { + } + + /** Spielerkopf mit Custom-Textur (Base64 des textures-JSON). */ + public static ItemStack playerHead(String base64Textures) { + return ItemStack.builder(Material.PLAYER_HEAD) + .set(DataComponents.PROFILE, new ResolvableProfile(new PlayerSkin(base64Textures, null))) + .build(); + } + + /** Gefaerbtes Lederruestungsteil, rgb z.B. 0xFFD83D. */ + public static ItemStack dyedLeather(Material leatherPiece, int rgb) { + return ItemStack.builder(leatherPiece) + .set(DataComponents.DYED_COLOR, new Color(rgb)) + .build(); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Partygoer.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Partygoer.java new file mode 100644 index 0000000..362e412 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Partygoer.java @@ -0,0 +1,49 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.item.Material; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.sound.SoundEvent; + +/** + * Partygoer: verfolgt unablaessig langsam den naechsten sichtbaren Spieler und + * "feiert" dabei hoerbar. Treffer verlangsamen den Spieler — die beginnende + * Verwandlung. + */ +public class Partygoer extends BackroomsMonster { + public Partygoer(LevelRuntime level) { + super(EntityType.ZOMBIE, level); + this.viewRange = 32; + this.attackDamage = 3; + this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.15); + this.getAttribute(Attribute.SCALE).setBaseValue(1.1); + this.setEquipment(EquipmentSlot.HELMET, MonsterAppearance.playerHead(MonsterAppearance.PARTYGOER_HEAD)); + this.setEquipment(EquipmentSlot.CHESTPLATE, MonsterAppearance.dyedLeather(Material.LEATHER_CHESTPLATE, 0xFFD83D)); + } + + @Override + public void monsterTick(long tick) { + if(tick % 160 < 2) { + SoundEvent celebration = this.level.getRnd().nextBoolean() + ? SoundEvent.ENTITY_VILLAGER_CELEBRATE + : SoundEvent.BLOCK_NOTE_BLOCK_BIT; + this.emitSound(celebration, 1f, 1.2f); + } + + this.target = this.nearestVisiblePlayer().orElse(null); + if(this.target != null) { + this.setState(State.CHASE, tick); + if(tick % 10 < 2) this.moveTo(this.target.getPosition(), 0.17); + if(this.tryAttack(this.target, tick)) { + this.target.addEffect(new Potion(PotionEffect.SLOWNESS, 0, 60)); + } + } else { + this.setState(State.IDLE, tick); + if(tick % 40 < 2) this.wander(0.1); + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Smiler.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Smiler.java new file mode 100644 index 0000000..728eb4a --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Smiler.java @@ -0,0 +1,94 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.Jumpscare; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.sound.SoundEvent; + +/** + * Smiler: unsichtbarer Koerper, nur der grinsende Kopf schwebt in der Dunkelheit. + * Dreht sich immer zum Spieler (das Grinsen folgt dir), friert ein, solange er + * direkt angesehen wird, und kriecht sonst stetig naeher. Verlaesst die Dunkelzone + * nur fuer den finalen Schlag. + */ +public class Smiler extends BackroomsMonster { + public Smiler(LevelRuntime level) { + super(EntityType.ZOMBIE, level); + this.viewRange = 16; + this.attackDamage = 4; + this.ignoresDarkness = true; + this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.11); + this.setInvisible(true); + this.setEquipment(EquipmentSlot.HELMET, MonsterAppearance.playerHead(MonsterAppearance.SMILER_HEAD)); + } + + @Override + public void monsterTick(long tick) { + // Ziel finden bzw. validieren — jeder sichtbare Spieler in Reichweite triggert + if(this.target == null || this.target.isRemoved() || !this.canSee(this.target)) { + Player next = this.nearestVisiblePlayer().orElse(null); + if(next != null && this.target == null) { + // Erster Sichtkontakt: leises Knistern beim Spieler + next.playSound( + Sound.sound(SoundEvent.BLOCK_SCULK_SENSOR_CLICKING, Sound.Source.HOSTILE, 0.6f, 0.5f), + Sound.Emitter.self() + ); + } + this.target = next; + } + + if(this.target == null) { + this.setState(State.IDLE, tick); + this.getNavigator().setPathTo(null); + return; + } + + this.setState(State.CHASE, tick); + // Das Grinsen folgt dir immer + this.lookAt(this.target); + + if(this.tryAttack(this.target, tick)) { + // Der Schlag aus der Dunkelheit ist DER Jumpscare-Moment des Smilers + Jumpscare.visual(this.target, this.getInstance()); + } + + // Blickkontakt friert ihn ein — wegsehen laesst ihn weiterkriechen + if(this.playerLooksAtMe(this.target)) { + this.getNavigator().setPathTo(null); + return; + } + + if(tick % 10 < 2) { + LevelLayout layout = this.level.getLayout(); + boolean selfInDark = layout.isDarkAt(this.getPosition()); + boolean closeKill = this.getDistance(this.target) < 4; + + if(selfInDark || closeKill) { + // Sprintende Beute macht ihn schneller + double speed = this.target.isSprinting() ? 0.18 : 0.11; + this.moveTo(this.target.getPosition(), speed); + } else { + // Zurueck in die Dunkelheit lauern + this.wanderIntoDark(); + } + } + } + + private void wanderIntoDark() { + LevelLayout layout = this.level.getLayout(); + int cx = layout.cellX(this.getPosition().x()); + int cz = layout.cellZ(this.getPosition().z()); + for(int attempt = 0; attempt < 8; attempt++) { + int nx = cx + this.level.getRnd().nextInt(5) - 2; + int nz = cz + this.level.getRnd().nextInt(5) - 2; + if(!layout.isWalkable(nx, nz) || !layout.isDarkCell(nx, nz)) continue; + this.moveTo(this.level.toPlayerPos(new int[]{nx, nz}), 0.11); + return; + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Wretch.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Wretch.java new file mode 100644 index 0000000..364a166 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Wretch.java @@ -0,0 +1,42 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.sound.SoundEvent; + +/** + * Wretch: ausgezehrter, durchgehend feindseliger Humanoid (Husk-Modell). + * Kein Warnverhalten wie der Hound — sieht er dich, rennt er sofort los. + * Schnell, aber schwach: der Schrecken liegt im Tempo. + */ +public class Wretch extends BackroomsMonster { + public Wretch(LevelRuntime level) { + super(EntityType.HUSK, level); + this.viewRange = 26; + this.attackDamage = 2; + this.attackCooldownTicks = 15; + this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.28); + this.getAttribute(Attribute.SCALE).setBaseValue(0.95); + } + + @Override + public void monsterTick(long tick) { + if(this.target == null || this.target.isRemoved() || !this.canSee(this.target)) { + this.target = this.nearestVisiblePlayer().orElse(null); + if(this.target != null) { + this.setState(State.CHASE, tick); + this.emitSound(SoundEvent.ENTITY_HUSK_AMBIENT, 1.2f, 0.6f); + } + } + + if(this.target == null) { + this.setState(State.IDLE, tick); + if(tick % 40 < 2) this.wander(0.12); + return; + } + + if(tick % 10 < 2) this.moveTo(this.target.getPosition(), 0.3); + this.tryAttack(this.target, tick); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/sanity/SanityManager.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/sanity/SanityManager.java new file mode 100644 index 0000000..885d378 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/sanity/SanityManager.java @@ -0,0 +1,101 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.sanity; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsPlayer; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambience.Jumpscare; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.ExecutionType; +import net.minestom.server.timer.TaskSchedule; + +import java.util.Random; + +/** + * Leichtgewichtige Sanity: sinkt in Dunkelheit und Monsternaehe, steigt im Licht. + * Niedrige Werte erzeugen Halluzinationen (Fake-Scares, Herzschlag, Dunkelheits-Pulse); + * toedlich ist Sanity nur im Hardcore-Modus. + */ +public class SanityManager { + private final BackroomsGame game; + private final boolean hardcore; + private final Random rnd = new Random(); + + public SanityManager(BackroomsGame game, boolean hardcore) { + this.game = game; + this.hardcore = hardcore; + } + + public void start() { + this.game.scheduler().submitTask(() -> { + if(!this.game.isRunning()) return TaskSchedule.stop(); + this.game.getPlayers().stream() + .filter(player -> player.getGameMode() == GameMode.ADVENTURE) + .forEach(player -> { + try { + this.tickPlayer(player); + } catch(Exception e) { + // Ein Fehler darf den Sanity-Task nicht dauerhaft toeten + net.minestom.server.MinecraftServer.getExceptionManager().handleException(e); + } + }); + return TaskSchedule.seconds(1); + }, ExecutionType.TICK_END); + } + + private void tickPlayer(Player player) { + BackroomsPlayer state = this.game.playerState(player); + if(state == null) return; + LevelRuntime level = this.game.getLevels().get(state.getLevelIndex()); + + double delta = 0.01; + boolean inDark = level.getDefinition().permanentDarkness() + || level.isBlackoutActive() + || level.getLayout().isDarkAt(player.getPosition()); + if(inDark) delta = -0.01; + + boolean monsterNearby = level.getMonsters().stream() + .anyMatch(monster -> !monster.isRemoved() && monster.getDistance(player) < 8); + if(monsterNearby) delta -= 0.02; + + // Dunkelheit ist nie sicher: kleine Chance auf einen Scare, unabhaengig von der Sanity + if(inDark && this.rnd.nextInt(100) < 3) { + if(this.rnd.nextInt(4) == 0) { + Jumpscare.visual(player, this.game); + } else { + Jumpscare.sound(player, this.rnd); + } + } + + state.setSanity(Math.clamp(state.getSanity() + delta, 0, 1)); + player.setExp((float) state.getSanity()); + + double sanity = state.getSanity(); + if(sanity < 0.6 && this.rnd.nextInt(100) < 5) { + player.playSound(Sound.sound(SoundEvent.AMBIENT_CAVE, Sound.Source.AMBIENT, 0.7f, 0.5f), Sound.Emitter.self()); + } + if(sanity < 0.4 && this.rnd.nextInt(100) < 8) { + Jumpscare.fake(player, this.rnd); + player.playSound(Sound.sound(SoundEvent.ENTITY_WARDEN_HEARTBEAT, Sound.Source.AMBIENT, 0.9f, 1f), Sound.Emitter.self()); + } + if(sanity < 0.2) { + if(this.rnd.nextInt(100) < 20) player.addEffect(new Potion(PotionEffect.DARKNESS, (byte) 0, 45)); + if(this.rnd.nextInt(100) < 5) Jumpscare.visual(player, this.game); + } + if(this.hardcore && sanity <= 0 && this.rnd.nextInt(3) == 0) { + player.damage(DamageType.MAGIC, 1); + } + } + + public void restore(Player player, double amount) { + BackroomsPlayer state = this.game.playerState(player); + if(state == null) return; + state.setSanity(Math.clamp(state.getSanity() + amount, 0, 1)); + player.setExp((float) state.getSanity()); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/transition/TransitionManager.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/transition/TransitionManager.java new file mode 100644 index 0000000..6413a99 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/transition/TransitionManager.java @@ -0,0 +1,87 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.transition; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsPlayer; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelLayout; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.TransitionType; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.sound.SoundEvent; + +/** + * Erkennt Level-Uebergaenge: Fall durch den Exit-Schacht (HOLE/LADDER), Betreten des + * Glitch-Bodens, Verweilen an der No-Clip-Wand und das Erreichen des Finale-Ziels. + */ +public class TransitionManager { + + private final BackroomsGame game; + + public TransitionManager(BackroomsGame game) { + this.game = game; + } + + public void onMove(Player player, Pos newPosition) { + BackroomsPlayer state = this.game.playerState(player); + if(state == null || state.isEscaped() || state.isEliminated()) return; + + LevelRuntime level = this.game.getLevels().get(state.getLevelIndex()); + TransitionType transition = level.getDefinition().transitionType(); + + // Fall durch den offenen Schacht ins naechste Level + if(newPosition.y() < level.getBaseY() - 4) { + this.game.advanceLevel(player, false); + return; + } + + // Finale: der Zieleinlauf ist die gesamte Korridorbreite ab der Exit-Zelle + if(transition == TransitionType.ESCAPE) { + int[] exit = level.getLayout().getExitCell(); + if(exit != null && level.getLayout().cellX(newPosition.x()) >= exit[0]) this.game.escape(player); + return; + } + + if(!this.isInExitCell(level, newPosition)) return; + + if(transition == TransitionType.GLITCH_FLOOR) this.game.advanceLevel(player, true); + } + + /** Wird alle 10 Ticks aufgerufen: Level-Abgleich und Rueckweg-Sperre. */ + public void onTick(Player player) { + BackroomsPlayer state = this.game.playerState(player); + if(state == null || state.isEscaped() || state.isEliminated()) return; + + boolean settled = System.currentTimeMillis() - state.getLevelEnteredMillis() > 5000; + if(!settled) return; + + // Selbstheilung: registriertes Level mit der physischen Hoehe abgleichen. + // Ohne diesen Abgleich laufen Spieler nach einem verpassten oder + // zurueckgekletterten Uebergang in einem Level herum, in dem sie nicht + // registriert sind — Monster ohne AI, Level-Events starten nicht. + LevelRuntime physical = this.game.levelAt((int) player.getPosition().y()); + if(physical == null || physical.getIndex() == state.getLevelIndex()) return; + + if(physical.getIndex() > state.getLevelIndex()) { + // Drift nach unten (verpasster Uebergang): Zustand nachziehen + while(state.getLevelIndex() < physical.getIndex()) { + this.game.advanceLevel(player, false); + } + } else { + // Rueckweg gesperrt: die Backrooms holen dich zurueck ins registrierte Level + LevelRuntime registered = this.game.getLevels().get(state.getLevelIndex()); + player.teleport(registered.randomSpawn()); + player.addEffect(new Potion(PotionEffect.BLINDNESS, (byte) 0, 40)); + player.playSound(Sound.sound(SoundEvent.BLOCK_PORTAL_TRAVEL, Sound.Source.AMBIENT, 0.4f, 0.4f), Sound.Emitter.self()); + } + } + + private boolean isInExitCell(LevelRuntime level, Pos position) { + LevelLayout layout = level.getLayout(); + int[] exit = layout.getExitCell(); + if(exit == null) return false; + return layout.cellX(position.x()) == exit[0] && layout.cellZ(position.z()) == exit[1]; + } +} diff --git a/src/main/resources/lang/locales.map.csv b/src/main/resources/lang/locales.map.csv index a28ce57..33b98fd 100644 --- a/src/main/resources/lang/locales.map.csv +++ b/src/main/resources/lang/locales.map.csv @@ -190,4 +190,25 @@ description;Jump on the right color!;Springe auf die richtige Farbe! ;; ns:game_PlayerRace#;; name;Player Race;Wettrennen -description;Get to the other side as quickly as possible!;Komme so schnell wie möglich auf die andere Seite! \ No newline at end of file +description;Get to the other side as quickly as possible!;Komme so schnell wie möglich auf die andere Seite!;; +ns:game_Backrooms#;; +name;Backrooms;Backrooms +description;Escape the Backrooms before the others do - or be the last one alive!;Entkomme den Backrooms vor allen anderen - oder überlebe als Letzter! +level_lobby;Level 0 - The Lobby;Level 0 - Die Lobby +level_habitable;Level 1 - Habitable Zone;Level 1 - Bewohnbare Zone +level_pipeDreams;Level 2 - Pipe Dreams;Level 2 - Rohrträume +level_poolrooms;The Poolrooms;Die Poolrooms +level_lightsOut;Level 6 - Lights Out;Level 6 - Licht aus +level_fun;Level Fun;Level Fun +level_finale;Level ! - Run For Your Life;Level ! - Lauf um dein Leben +optionLevels;Number of levels;Anzahl der Level +optionLives;Lives;Leben +optionMonsterDensity;Monster density;Monsterdichte +optionSize;Level size - 0 is auto;Levelgroesse - 0 ist automatisch +optionHardcoreSanity;Hardcore sanity;Hardcore-Verstand +escaped;You escaped the Backrooms!;Du bist den Backrooms entkommen! +eliminated;The Backrooms got you;Die Backrooms haben dich +died;You died - but the Backrooms are not done with you;Du bist gestorben - aber die Backrooms sind noch nicht fertig mit dir +finaleRun;RUN!;RENN! +finaleCountdown;The horde arrives in;Die Horde kommt in +doorOpened;Someone opened the exit door - all keys for this level crumble to dust;Jemand hat die Ausgangstuer geoeffnet - alle Schluessel dieses Levels zerfallen zu Staub