added Bacteria monster to Backrooms game with camouflage mechanics, lure sounds, and sprinting behavior

This commit is contained in:
2026-08-15 16:10:12 +02:00
parent 817693239b
commit 8a9f3a8ad0
6 changed files with 245 additions and 10 deletions
@@ -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.LevelRuntime;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; 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.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.BackroomsMonster;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; 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 eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Smiler;
@@ -110,7 +111,7 @@ public class Level0Lobby extends LevelDefinition {
return true; return true;
} }
/** Smiler lauern ausschliesslich in den dunklen Blackout-Zonen. */ /** Smiler lauern in den Blackout-Zonen; vereinzelt steht eine Bacteria als "Wand" herum. */
@Override @Override
public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) { public void populateMonsters(BackroomsGame game, LevelRuntime runtime, int density) {
LevelLayout layout = runtime.getLayout(); LevelLayout layout = runtime.getLayout();
@@ -119,13 +120,21 @@ public class Level0Lobby extends LevelDefinition {
smiler.spawnAtCell(game, cell); smiler.spawnAtCell(game, cell);
runtime.getMonsters().add(smiler); 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 @Override
public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) {
if(runtime.getLayout().isDarkCell(cell[0], cell[1])) return new Smiler(runtime); 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 @Override
@@ -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.LevelRuntime;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelType; 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.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.BackroomsMonster;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Hound; 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 eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.monster.Wretch;
@@ -95,12 +96,21 @@ public class LevelHabitableZone extends LevelDefinition {
hound.spawnAtCell(game, cell); hound.spawnAtCell(game, cell);
runtime.getMonsters().add(hound); 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 @Override
public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) { 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. */ /** "The Flickering": zufaellig faellt fuer wenige Sekunden das komplette Licht aus. */
@@ -105,13 +105,22 @@ public abstract class BackroomsMonster extends EntityCreature {
} }
} }
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)) 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 -> { .exceptionally(throwable -> {
MinecraftServer.getExceptionManager().handleException(throwable); MinecraftServer.getExceptionManager().handleException(throwable);
return null; 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 */ /** Monster, die selbst in der Dunkelheit leben, verlieren dort keine Sichtweite */
protected boolean ignoresDarkness = false; protected boolean ignoresDarkness = false;
@@ -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<Entity> 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);
}
}
@@ -15,9 +15,9 @@ public final class MonsterAppearance {
/** Hound (Backrooms) — duesterer Hundekopf. */ /** Hound (Backrooms) — duesterer Hundekopf. */
public static final String HOUND_HEAD = public static final String HOUND_HEAD =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDgzMzhiMGIxNDkyMDU5YzIzMGNjYzg5ZmNhZTE0ZThhODU1ZTcwYjllNWVhNDVjYWIzZjVmNWI5ODJkYzc4MiJ9fX0="; "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDgzMzhiMGIxNDkyMDU5YzIzMGNjYzg5ZmNhZTE0ZThhODU1ZTcwYjllNWVhNDVjYWIzZjVmNWI5ODJkYzc4MiJ9fX0=";
/** Smiler — weisses Grinsen auf schwarzem Grund. */ /** Smiler — weisses Grinsen auf schwarzem Grund (minecraft-heads.com #74583). */
public static final String SMILER_HEAD = public static final String SMILER_HEAD =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYWEyMDcyYzkxNDhmNjc2N2ZlYzBlMzNiNWRiYTAyN2EyOGM0NDcyMmRlODUzYTJkNGRjMDAxN2UzMmJkMDE1OCJ9fX0="; "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvODc2ZmViODIxZTVkYTE1NTRiODkzZmM1MzY1ZjdiN2E5ODY3M2QxZjliNzU3OTZiODdmNzRlM2M5NjFjMGU3OCJ9fX0=";
/** Partygoer — gelber Smiley. */ /** Partygoer — gelber Smiley. */
public static final String PARTYGOER_HEAD = public static final String PARTYGOER_HEAD =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDI1MzUwODhkNDQ1ZDZhNDQ5ODc2YWRhOTg4ZmExMDc5ZDU3MmQyMTQ3MTNiYjNkY2VhMWRhM2JjNGY3ZDk2YiJ9fX0="; "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDI1MzUwODhkNDQ1ZDZhNDQ5ODc2YWRhOTg4ZmExMDc5ZDU3MmQyMTQ3MTNiYjNkY2VhMWRhM2JjNGY3ZDk2YiJ9fX0=";
@@ -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.LevelLayout;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime; import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.level.LevelRuntime;
import net.kyori.adventure.sound.Sound; 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.EntityType;
import net.minestom.server.entity.EquipmentSlot;
import net.minestom.server.entity.Player; import net.minestom.server.entity.Player;
import net.minestom.server.entity.attribute.Attribute; 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; import net.minestom.server.sound.SoundEvent;
/** /**
@@ -17,6 +20,13 @@ import net.minestom.server.sound.SoundEvent;
* nur fuer den finalen Schlag. * nur fuer den finalen Schlag.
*/ */
public class Smiler extends BackroomsMonster { 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) { public Smiler(LevelRuntime level) {
super(EntityType.ZOMBIE, level); super(EntityType.ZOMBIE, level);
this.viewRange = 16; this.viewRange = 16;
@@ -24,7 +34,35 @@ public class Smiler extends BackroomsMonster {
this.ignoresDarkness = true; this.ignoresDarkness = true;
this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.11); this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.11);
this.setInvisible(true); 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 @Override