From 8a9f3a8ad08bd9888961ec634671e7495113fb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 15 Aug 2026 16:10:12 +0200 Subject: [PATCH] added Bacteria monster to Backrooms game with camouflage mechanics, lure sounds, and sprinting behavior --- .../backrooms/level/impl/Level0Lobby.java | 15 +- .../level/impl/LevelHabitableZone.java | 14 +- .../backrooms/monster/BackroomsMonster.java | 11 +- .../types/backrooms/monster/Bacteria.java | 169 ++++++++++++++++++ .../backrooms/monster/MonsterAppearance.java | 4 +- .../types/backrooms/monster/Smiler.java | 42 ++++- 6 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Bacteria.java 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 index a6a5b96..ecf10d4 100644 --- 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 @@ -9,6 +9,7 @@ import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.L 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.Bacteria; 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; @@ -110,7 +111,7 @@ public class Level0Lobby extends LevelDefinition { return true; } - /** Smiler lauern ausschliesslich in den dunklen Blackout-Zonen. */ + /** Smiler lauern in den Blackout-Zonen; vereinzelt steht eine Bacteria als "Wand" herum. */ @Override public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { LevelLayout layout = runtime.getLayout(); @@ -119,13 +120,21 @@ public class Level0Lobby extends LevelDefinition { smiler.spawnAtCell(game, cell); runtime.getMonsters().add(smiler); } + for(int[] cell : Cells.farWalkable(layout, runtime.getRnd(), Math.max(1, density - 1))) { + Bacteria bacteria = new Bacteria(runtime); + bacteria.spawnAtCell(game, cell); + runtime.getMonsters().add(bacteria); + } } - /** Nachschub: Smiler in Dunkelzonen, selten ein Hound in den hellen Fluren. */ + /** Nachschub: Smiler in Dunkelzonen, in den hellen Fluren selten Hound oder Bacteria. */ @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; + int roll = runtime.getRnd().nextInt(100); + if(roll < 20) return new Hound(runtime); + if(roll < 35) return new Bacteria(runtime); + return null; } @Override 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 index 9723268..fcf3fb8 100644 --- 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 @@ -8,6 +8,7 @@ import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.L 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.Bacteria; 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; @@ -95,12 +96,21 @@ public class LevelHabitableZone extends LevelDefinition { hound.spawnAtCell(game, cell); runtime.getMonsters().add(hound); } + // Getarnte Bacteria zwischen den Betonpfeilern + for(int[] cell : Cells.farWalkable(runtime.getLayout(), runtime.getRnd(), density)) { + Bacteria bacteria = new Bacteria(runtime); + bacteria.spawnAtCell(game, cell); + runtime.getMonsters().add(bacteria); + } } - /** Nachschub: meist Hounds, gelegentlich ein schneller Wretch. */ + /** Nachschub: meist Hounds, dazu Wretches und getarnte Bacteria. */ @Override public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { - return runtime.getRnd().nextInt(100) < 70 ? new Hound(runtime) : new Wretch(runtime); + int roll = runtime.getRnd().nextInt(100); + if(roll < 60) return new Hound(runtime); + if(roll < 85) return new Wretch(runtime); + return new Bacteria(runtime); } /** "The Flickering": zufaellig faellt fuer wenige Sekunden das komplette Licht aus. */ 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 index 712bc6a..deea685 100644 --- 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 @@ -105,13 +105,22 @@ public abstract class BackroomsMonster extends EntityCreature { } } CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)) - .thenRun(() -> this.setInstance(instance, position)) + .thenRun(() -> { + this.setInstance(instance, position); + MinecraftServer.getSchedulerManager().scheduleNextTick(() -> { + if(!this.isRemoved()) this.onSpawned(); + }); + }) .exceptionally(throwable -> { MinecraftServer.getExceptionManager().handleException(throwable); return null; }); } + /** Wird einen Tick nach dem Spawn aufgerufen (z. B. fuer Passagier-Displays). */ + protected void onSpawned() { + } + /** Monster, die selbst in der Dunkelheit leben, verlieren dort keine Sichtweite */ protected boolean ignoresDarkness = false; diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Bacteria.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Bacteria.java new file mode 100644 index 0000000..68aac22 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/backrooms/monster/Bacteria.java @@ -0,0 +1,169 @@ +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.coordinate.Vec; +import net.minestom.server.entity.Entity; +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.display.BlockDisplayMeta; +import net.minestom.server.instance.block.Block; +import net.minestom.server.sound.SoundEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * The Bacteria (nach Kane Pixels): eine turmhohe, skelettartige Gestalt mit + * ueberlangen Gliedmassen in unnatuerlichen Winkeln. Im Stillstand traegt sie die + * Wandtextur des Levels und verschmilzt mit der Umgebung — erwacht sie, faerbt + * sich der Koerper schlagartig schwarz. Sie lockt Wanderer mit imitierten + * menschlichen Geraeuschen an und bricht dann in einen rasend schnellen Sprint aus. + * Schleichende Spieler nimmt sie kaum wahr. + */ +public class Bacteria extends BackroomsMonster { + /** Distanz, ab der ein normal gehender Spieler den Angriff ausloest */ + private static final double TRIGGER_DISTANCE = 7; + /** Schleichende Spieler sind fast unsichtbar fuer ihn */ + private static final double SNEAK_TRIGGER_DISTANCE = 3.5; + /** Lock-Geraeusche fuer Spieler in diesem Ring */ + private static final double LURE_MIN = 8; + private static final double LURE_MAX = 24; + private static final double SPRINT_SPEED = 0.45; + private static final int CHASE_TIMEOUT_TICKS = 300; + /** + * Passagiere sitzen beim Zombie ~1.6 Bloecke ueber den Fuessen auf, Block-Displays + * rendern von ihrer Position nach oben — dieser Versatz stellt die Gestalt auf den + * Boden, sodass der Kopf auch unter 3er-Decken bleibt. + */ + private static final double BODY_Y_OFFSET = -1.6; + + private final List bodyDisplays = new ArrayList<>(); + private long lastLureTick = 0; + /** Wandtextur des Levels — die Tarnfarbe im Stillstand */ + private Block camouflageBlock = Block.BLACK_CONCRETE; + + public Bacteria(LevelRuntime level) { + super(EntityType.ZOMBIE, level); + this.viewRange = 24; + this.attackDamage = 5; + this.attackCooldownTicks = 25; + this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(SPRINT_SPEED); + this.setInvisible(true); + } + + /** + * Koerper nach der Lore: eine skelettartige, humanoide Gestalt aus mehreren + * duennen, schwarzen, draht-artigen Segmenten — ueberlange Gliedmassen in + * unnatuerlichen Winkeln. Displays haben keine Kollision, der Traeger-Zombie + * bleibt normal gross und passt durch alle Raeume. + */ + @Override + protected void onSpawned() { + // Spawnt getarnt in der Wandtextur; die Jagd faerbt den Koerper schwarz + this.camouflageBlock = this.level.getDefinition().palette().wall().getFirst(); + Block wire = this.camouflageBlock; + double height = this.level.getDefinition().palette().roomHeight() - 0.2; + + // Beine: zwei duenne, leicht verkantete Staebe + this.spawnBodyPart(wire, new Vec(0.12, height * 0.5, 0.12), new Vec(-0.3, BODY_Y_OFFSET, -0.06), zTilt(8)); + this.spawnBodyPart(wire, new Vec(0.12, height * 0.5, 0.12), new Vec(0.16, BODY_Y_OFFSET, 0.02), zTilt(-6)); + // Torso: schmaler, schief sitzender Kern + this.spawnBodyPart(wire, new Vec(0.22, height * 0.45, 0.22), new Vec(-0.11, BODY_Y_OFFSET + height * 0.45, -0.11), zTilt(-4)); + // Arme: ueberlang, in seltsamen Winkeln abgespreizt + this.spawnBodyPart(wire, new Vec(0.1, height * 0.5, 0.1), new Vec(-0.45, BODY_Y_OFFSET + height * 0.42, 0), zTilt(25)); + this.spawnBodyPart(wire, new Vec(0.1, height * 0.55, 0.1), new Vec(0.32, BODY_Y_OFFSET + height * 0.38, 0.06), zTilt(-18)); + // Kopf: kleiner, versetzter Klumpen ohne Gesicht + this.spawnBodyPart(wire, new Vec(0.32, 0.35, 0.32), new Vec(-0.16, BODY_Y_OFFSET + height * 0.9, -0.16), null); + } + + /** Faerbt alle Koerperteile um (Tarnung an/aus). */ + private void setBodyBlock(Block block) { + this.bodyDisplays.forEach(display -> + ((BlockDisplayMeta) display.getEntityMeta()).setBlockState(block) + ); + } + + /** Quaternion fuer eine Neigung um die Z-Achse (Grad). */ + private static float[] zTilt(double degrees) { + double half = Math.toRadians(degrees) / 2; + return new float[]{0, 0, (float) Math.sin(half), (float) Math.cos(half)}; + } + + private void spawnBodyPart(Block block, Vec scale, Vec translation, float[] leftRotation) { + Entity display = new Entity(EntityType.BLOCK_DISPLAY); + BlockDisplayMeta meta = (BlockDisplayMeta) display.getEntityMeta(); + meta.setBlockState(block); + meta.setScale(scale); + meta.setTranslation(translation); + if(leftRotation != null) meta.setLeftRotation(leftRotation); + display.updateViewableRule(viewer -> + Math.abs(viewer.getPosition().y() - this.getPosition().y()) < LevelRuntime.LEVEL_HEIGHT + ); + display.setInstance(this.getInstance(), this.getPosition()); + this.addPassenger(display); + this.bodyDisplays.add(display); + } + + @Override + public void remove() { + this.bodyDisplays.forEach(Entity::remove); + super.remove(); + } + + @Override + public void monsterTick(long tick) { + if(this.state == State.CHASE) { + this.chaseTick(tick); + return; + } + + // Tarnung: absolute Regungslosigkeit — er IST die Wand + Player closest = this.level.playersInLevel().stream() + .min((a, b) -> Double.compare(this.getDistance(a), this.getDistance(b))) + .orElse(null); + if(closest == null) return; + + double distance = this.getDistance(closest); + double trigger = closest.isSneaking() ? SNEAK_TRIGGER_DISTANCE : TRIGGER_DISTANCE; + + if(distance < trigger) { + // Ausbruch: die Tarnung faellt — der Koerper wird schwarz, dann der Schrei und der Sprint + this.target = closest; + this.setState(State.CHASE, tick); + this.setBodyBlock(Block.BLACK_CONCRETE); + this.emitSound(SoundEvent.ENTITY_GHAST_SCREAM, 1.6f, 0.4f); + this.emitSound(SoundEvent.ENTITY_ENDERMAN_SCREAM, 1.2f, 0.4f); + return; + } + + // Koeder: imitierte menschliche Geraeusche locken Spieler in seine Richtung + if(distance >= LURE_MIN && distance <= LURE_MAX && tick - this.lastLureTick > 240) { + this.lastLureTick = tick; + if(this.level.getRnd().nextBoolean()) { + this.emitSound(SoundEvent.BLOCK_STONE_STEP, 0.9f, 0.8f); + } else { + this.emitSound(SoundEvent.ENTITY_ZOMBIE_AMBIENT, 0.7f, 0.35f); + } + } + } + + private void chaseTick(long tick) { + if(this.target == null || this.target.isRemoved() + || !this.level.playersInLevel().contains(this.target) + || tick - this.stateSince > CHASE_TIMEOUT_TICKS + || this.getDistance(this.target) > this.viewRange) { + // Beute verloren: schlagartig erstarren und zurueck in die Tarnfarbe + this.target = null; + this.setState(State.IDLE, tick); + this.getNavigator().setPathTo(null); + this.setBodyBlock(this.camouflageBlock); + this.emitSound(SoundEvent.BLOCK_STONE_PLACE, 0.8f, 0.5f); + return; + } + + if(tick % 6 < 2) this.moveTo(this.target.getPosition(), SPRINT_SPEED); + this.tryAttack(this.target, 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 index d8209d9..1bb9472 100644 --- 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 @@ -15,9 +15,9 @@ public final class MonsterAppearance { /** Hound (Backrooms) — duesterer Hundekopf. */ public static final String HOUND_HEAD = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDgzMzhiMGIxNDkyMDU5YzIzMGNjYzg5ZmNhZTE0ZThhODU1ZTcwYjllNWVhNDVjYWIzZjVmNWI5ODJkYzc4MiJ9fX0="; - /** Smiler — weisses Grinsen auf schwarzem Grund. */ + /** Smiler — weisses Grinsen auf schwarzem Grund (minecraft-heads.com #74583). */ public static final String SMILER_HEAD = - "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYWEyMDcyYzkxNDhmNjc2N2ZlYzBlMzNiNWRiYTAyN2EyOGM0NDcyMmRlODUzYTJkNGRjMDAxN2UzMmJkMDE1OCJ9fX0="; + "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvODc2ZmViODIxZTVkYTE1NTRiODkzZmM1MzY1ZjdiN2E5ODY3M2QxZjliNzU3OTZiODdmNzRlM2M5NjFjMGU3OCJ9fX0="; /** Partygoer — gelber Smiley. */ public static final String PARTYGOER_HEAD = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDI1MzUwODhkNDQ1ZDZhNDQ5ODc2YWRhOTg4ZmExMDc5ZDU3MmQyMTQ3MTNiYjNkY2VhMWRhM2JjNGY3ZDk2YiJ9fX0="; 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 index 728eb4a..5a84643 100644 --- 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 @@ -4,10 +4,13 @@ import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.ambienc 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.Vec; +import net.minestom.server.entity.Entity; 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.entity.metadata.display.AbstractDisplayMeta; +import net.minestom.server.entity.metadata.display.ItemDisplayMeta; import net.minestom.server.sound.SoundEvent; /** @@ -17,6 +20,13 @@ import net.minestom.server.sound.SoundEvent; * nur fuer den finalen Schlag. */ public class Smiler extends BackroomsMonster { + /** Groesse des Grinsens (Item-Display, unabhaengig von der Koerpergroesse) */ + private static final float GRIN_SCALE = 2.2f; + /** Versatz relativ zum Aufsitzpunkt: Grinsen auf Brust-/Augenhoehe, nicht im Boden */ + private static final float GRIN_Y_OFFSET = 0.4f; + + private Entity headDisplay; + public Smiler(LevelRuntime level) { super(EntityType.ZOMBIE, level); this.viewRange = 16; @@ -24,7 +34,35 @@ public class Smiler extends BackroomsMonster { this.ignoresDarkness = true; this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.11); this.setInvisible(true); - this.setEquipment(EquipmentSlot.HELMET, MonsterAppearance.playerHead(MonsterAppearance.SMILER_HEAD)); + } + + /** + * Das Grinsen ist ein grosses Item-Display, das als Passagier mitfaehrt: + * frei skalierbar, tief positioniert, und per Billboard dreht es sich + * clientseitig immer zu jedem Betrachter. + */ + @Override + protected void onSpawned() { + this.headDisplay = new Entity(EntityType.ITEM_DISPLAY); + ItemDisplayMeta meta = (ItemDisplayMeta) this.headDisplay.getEntityMeta(); + meta.setItemStack(MonsterAppearance.playerHead(MonsterAppearance.SMILER_HEAD)); + meta.setScale(new Vec(GRIN_SCALE, GRIN_SCALE, GRIN_SCALE)); + meta.setTranslation(new Vec(0, GRIN_Y_OFFSET, 0)); + meta.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER); + // Player-Heads rendern im Item-Display mit dem Hinterkopf zur Kamera — + // 180 Grad um die Y-Achse drehen, damit das Grinsen den Betrachter ansieht + meta.setLeftRotation(new float[]{0, 1, 0, 0}); + this.headDisplay.updateViewableRule(viewer -> + Math.abs(viewer.getPosition().y() - this.getPosition().y()) < LevelRuntime.LEVEL_HEIGHT + ); + this.headDisplay.setInstance(this.getInstance(), this.getPosition()); + this.addPassenger(this.headDisplay); + } + + @Override + public void remove() { + if(this.headDisplay != null) this.headDisplay.remove(); + super.remove(); } @Override