63 Commits

Author SHA1 Message Date
MineTec 8a9f3a8ad0 added Bacteria monster to Backrooms game with camouflage mechanics, lure sounds, and sprinting behavior 2026-08-15 16:10:12 +02:00
MineTec 817693239b refactored Backrooms game to introduce modular game systems, procedural generation, and enhanced mechanics 2026-08-09 22:05:44 +02:00
MineTec 26eff149d4 removed deprecated use of old DimensionType constructor and updated map file paths accordingly 2026-08-09 12:35:05 +02:00
jannis 7734b225eb Merge pull request 'added playerrace and updated to version 26.2' (#14) from develop-jannis into develop
Reviewed-on: #14
Reviewed-by: Lars Neuhaus <larslukasneuhaus@gmx.de>
2026-07-31 20:04:15 +00:00
Pupsi 1a9b9f5fd2 added return to player race position check 2026-07-31 13:28:27 +02:00
jannis b0cf527977 removed unnecessary comments 2026-07-29 15:59:08 +02:00
jannis 01646546a3 fixen issues with playerrace and changed the item selection in pillars 2026-07-26 22:03:11 +02:00
jannis 13a0cdde3a changed gametype of playerrace to jumpnrun and changed symbol to dirt path in playerrace 2026-07-20 21:12:33 +02:00
jannis 7d60c74d85 fixed generation ofo elytrarace 2026-07-20 13:47:25 +02:00
MineTec 0d7f1fc646 updated dependencies and updated all usages accordingly, replaced deprecated methods, and updated project to JDK 25 2026-07-19 19:37:44 +02:00
jannis ee421245d1 added translations 2026-07-19 01:50:34 +02:00
jannis d75332927c fixed that something can generate twice in playerrace 2026-07-19 01:31:53 +02:00
jannis 2c579f4afe fixen generating problem and finished logic of playerrace 2026-07-19 01:11:56 +02:00
jannis 82984cf5cf added LadderWall2 and added checkpoints 2026-07-19 00:16:12 +02:00
MineTec 14f6a38212 merged origin/develop-jannis 2026-07-18 22:15:03 +02:00
MineTec 3bdc4113c0 added 20 obstacle types, adjustable track length and forked obstacle generation 2026-07-18 22:14:29 +02:00
jannis b7e292fe0e added LadderWall 2026-07-18 21:35:00 +02:00
Pupsi 57cd5acdfd added cos function to mudpit 2026-07-18 18:57:33 +02:00
Pupsi 0f72b8049d simplified block loop from generator 2026-07-18 18:07:20 +02:00
jannis 9992b07ed5 added basic obstacle mechanic 2026-07-18 17:41:45 +02:00
jannis f55148203e started player race 2026-07-18 15:15:29 +02:00
jannis 41753f2805 Merge pull request 'Finalized ColorJump' (#13) from develop-jannis into develop
Reviewed-on: #13
Reviewed-by: Lars Neuhaus <larslukasneuhaus@gmx.de>
2026-07-15 16:15:14 +00:00
jannis 9bdb98cdc9 other way for the effective-final 2026-07-13 16:34:07 +02:00
jannis 6f0ebe4d12 moved colorjump from prototype to jumpnrun 2026-07-13 10:40:58 +02:00
jannis 58983be156 removed off-hand swap in colorjump 2026-07-13 10:30:34 +02:00
jannis ee7d434d7e perfectet colorjump timer, cancelled inventory movement 2026-02-16 22:28:32 +01:00
jannis 410f7b4027 perfekted colorjump 2026-02-16 21:07:30 +01:00
jannis e1515200ab stoped task at game end 2026-02-11 20:56:26 +01:00
jannis 27005f4fc4 Merge pull request 'added color jump' (#12) from develop-colorjump into develop
Reviewed-on: #12
Reviewed-by: Lars Neuhaus <larslukasneuhaus@gmx.de>
2026-02-10 20:24:17 +00:00
jannis 2808393c23 solved pr comments 2026-02-10 21:22:32 +01:00
jannis d0825695b3 added an end to color jump 2026-02-10 19:23:06 +01:00
jannis e822a5c5e2 fixed translations for color jump 2026-02-10 19:17:37 +01:00
jannis bf95c8dcc2 added translations to color jump 2026-02-10 19:12:36 +01:00
jannis 2f70b25e87 added ColorJump 2026-02-10 19:08:07 +01:00
Pupsi cbec1ea7f8 Merge pull request 'develop-tetris-srs' (#11) from develop-tetris-srs into develop
Reviewed-on: #11
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2026-02-06 10:47:27 +00:00
Pupsi c176cfaf2c solved pr comments; switched from hard lock task to reset counter 2026-02-04 19:25:03 +01:00
Pupsi 56f56d48b6 added airborne lock timer reset 2026-02-03 19:22:33 +01:00
Pupsi 991e51bc10 fixed ground behavior for tetrominoes 2026-02-03 19:08:05 +01:00
Pupsi ffba86ac41 removed cause of tetris collision bugs (yaw, pitch in Pos) 2026-02-03 17:18:28 +01:00
Pupsi f95c670514 removed cause of tetris collision bugs (yaw, pitch in Pos) 2026-02-03 17:18:10 +01:00
Pupsi 0b3c757ce0 fixed srs rotations 2026-02-03 15:51:41 +01:00
Pupsi 76df6643db added simplified SRS; not functional for I-Tetromino 2026-02-02 22:37:43 +01:00
jannis 7186b4dbea Merge pull request 'added pillars and block battle' (#10) from develop-pillars into develop
Reviewed-on: #10
Reviewed-by: Lars Neuhaus <larslukasneuhaus@gmx.de>
2026-02-02 19:09:44 +00:00
jannis 24d79d7a11 pull requestest changes 2026-02-02 20:04:55 +01:00
jannis 70fd5bafdb updated Block Battle description 2026-02-02 18:19:45 +00:00
jannis 11ddd01470 pull requestest changes 2026-02-02 19:15:46 +01:00
jannis 7d5fb025bd added setting, refactored code 2026-02-01 18:24:36 +01:00
jannis 05240660f2 added scores and translations 2026-02-01 17:37:37 +01:00
jannis d0031b4ea5 added BlockBattle 2026-02-01 14:14:34 +01:00
jannis 360266339d added pillars game 2026-01-23 20:26:04 +01:00
jannis 325fba2a53 Merge pull request 'added second option to game selection menu, finished stickfight(added timer to stick fight)' (#9) from develop-jannis into develop
Reviewed-on: #9
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2026-01-11 19:07:05 +00:00
jannis e760cdb2c6 removed 0 add option seconds 2026-01-10 21:41:45 +01:00
jannis c111c027ff added second option to game selection menu, finished stickfight 2026-01-10 17:41:19 +01:00
Pupsi 73ab137ae4 Merge remote-tracking branch 'origin/develop' into develop 2026-01-10 16:29:26 +01:00
Pupsi 9a9e646288 fixed Tetris camera shake 2026-01-10 16:29:12 +01:00
jannis 728dc92fc8 Merge pull request 'elytra race fix boost ring 1' (#8) from develop-jannis into develop
Reviewed-on: #8
2026-01-10 13:48:50 +00:00
jannis 2f8ff36e5e Merge branch 'develop' into develop-jannis 2026-01-10 13:48:39 +00:00
jannis 5cb71c5c32 changed respawn height on first respawn in elytrarace
added level indicator for stickfight
2026-01-10 14:47:57 +01:00
jannis 5ab42fde4b Merge pull request 'added stick to inventory for stick fight' (#7) from develop-stickfight into develop
Reviewed-on: #7
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2026-01-10 13:10:26 +00:00
jannis d5c2f06409 elytra race fix boost ring 1 2026-01-10 14:02:21 +01:00
jannis 1ff8cca7e9 added stick to inventory for stick fight 2026-01-10 00:41:24 +01:00
MineTec 47c40c4941 fixed winning condition in SpaceSnake 2026-01-07 15:06:12 +01:00
MineTec d4a7fcada7 improved chat formatting by adding username colors and better separators 2026-01-02 02:26:56 +01:00
200 changed files with 7630 additions and 469 deletions
+7
View File
@@ -3,4 +3,11 @@
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="Minigames" options="-Xlint:deprecation" />
<module name="Minigames.test" options="-Xlint:deprecation" />
<module name="eu.mhsl.minenet.Minigames.main" options="-Xlint:deprecation" />
</option>
</component>
</project>
+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="openjdk-25" project-jdk-type="JavaSDK" />
</project>
+17 -40
View File
@@ -3,7 +3,7 @@
plugins {
id 'java'
id "com.github.johnrengelman.shadow" version "7.1.0"
id "com.gradleup.shadow" version "9.6.0"
}
group 'eu.mhsl.minenet'
@@ -11,58 +11,33 @@ version '1.0-SNAPSHOT'
repositories {
mavenCentral()
google()
maven {
url 'https://jitpack.io'
}
maven {
url "https://repo.unnamed.team/repository/unnamed-public/"
}
maven {
url 'https://oss.sonatype.org/content/repositories/snapshots'
}
}
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
testImplementation 'org.junit.jupiter:junit-jupiter:6.1.2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
//https://jitpack.io/#Minestom/Minestom
implementation 'net.minestom:minestom-snapshots:fd51c8d17a'
//https://mvnrepository.com/artifact/net.minestom/minestom
implementation 'net.minestom:minestom:2026.07.12-26.2'
//Tools
implementation 'de.articdive:jnoise:3.0.2'
implementation 'de.articdive:jnoise-pipeline:4.1.0'
compileOnly 'it.unimi.dsi:fastutil:8.5.18' // von Minestom nur zur Laufzeit bereitgestellt, fuer BatchUtil-Reflection benoetigt
implementation 'net.md-5:bungeecord-config:1.21-R0.3'
implementation 'org.apache.commons:commons-text:1.10.0'
implementation 'org.spongepowered:configurate-yaml:4.1.2'
implementation 'org.apache.commons:commons-text:1.15.0'
implementation 'org.spongepowered:configurate-yaml:4.2.0'
implementation 'com.sparkjava:spark-core:2.9.4'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.google.guava:guava:32.0.1-android'
implementation 'com.google.code.gson:gson:2.14.0'
implementation 'com.google.guava:guava:33.4.8-jre'
//PvP
implementation 'io.github.TogAr2:MinestomPvP:PR62-SNAPSHOT'
// Hephaestus engine
// implementation("team.unnamed:hephaestus-api:0.2.1-SNAPSHOT")
// implementation("team.unnamed:hephaestus-reader-blockbench:0.2.1-SNAPSHOT")
// implementation("team.unnamed:hephaestus-runtime-minestom:0.2.1-SNAPSHOT")
implementation 'io.github.togar2:MinestomPvP:2026.05.30-26.1.1'
}
tasks {
@@ -71,8 +46,6 @@ tasks {
attributes 'Main-Class': 'eu.mhsl.minenet.minigames.Main'
attributes 'Multi-Release': true
}
duplicatesStrategy = 'exclude'
from configurations.compileClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
build {
dependsOn(shadowJar)
@@ -89,3 +62,7 @@ tasks.register('copyJarToServer', Exec) {
commandLine 'scp', 'build/libs/Minigames-1.0-SNAPSHOT.jar', 'root@10.20.6.5:/root/minigames'
}
tasks.withType(JavaCompile) {
options.compilerArgs << "-Xlint:deprecation"
}
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+7 -11
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -173,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
@@ -3,12 +3,12 @@ package eu.mhsl.minenet.minigames;
import eu.mhsl.minenet.minigames.api.HttpServer;
import eu.mhsl.minenet.minigames.command.Commands;
import eu.mhsl.minenet.minigames.handler.Listeners;
import eu.mhsl.minenet.minigames.instance.Dimension;
import eu.mhsl.minenet.minigames.lang.Languages;
import eu.mhsl.minenet.minigames.server.tasks.TablistUpdateTask;
import net.minestom.server.Auth;
import net.minestom.server.MinecraftServer;
import net.minestom.server.extras.bungee.BungeeCordProxy;
import net.minestom.server.extras.lan.OpenToLAN;
import net.minestom.server.extras.velocity.VelocityProxy;
import net.minestom.server.timer.TaskSchedule;
import org.spongepowered.configurate.ConfigurateException;
import org.spongepowered.configurate.ConfigurationNode;
@@ -25,7 +25,7 @@ public class Main {
public static ConfigurationNode globalConfig;
@SuppressWarnings("ResultOfMethodCallIgnored")
public static void main(String[] args) throws ConfigurateException {
static void main(String[] args) throws ConfigurateException {
//noinspection ResultOfMethodCallIgnored
Resource.values(); // This initializes and preloads the enum and extracts the resources
Languages.getInstance(); //Preload languages into the jvm
@@ -38,14 +38,31 @@ public class Main {
ConfigurationNode serverConfig = globalConfig.node("server");
// muss vor MinecraftServer.init() gesetzt werden: ServerFlag liest die Properties statisch beim Klassenladen;
// bei fehlendem/ungueltigem Config-Wert den Minestom-Default behalten (0 wuerde keine Chunks senden)
int viewDistance = serverConfig.node("view-distance").getInt(0);
if(viewDistance > 0) {
System.setProperty("minestom.chunk-view-distance", String.valueOf(viewDistance));
}
logger.info("Initialize Minecraft server...");
MinecraftServer server = MinecraftServer.init();
Auth auth = new Auth.Offline();
if(globalConfig.node("bungeecord", "enabled").getBoolean()) {
auth = new Auth.Bungee();
} else if(globalConfig.node("velocity", "enabled").getBoolean()) {
auth = new Auth.Velocity(Objects.requireNonNull(globalConfig.node("velocity", "secret").getString()));
}
MinecraftServer server = MinecraftServer.init(auth);
// MinestomPvP.init();
// Dimensionen muessen vor server.start() registriert werden:
// seit Minestom 26.x sind die dynamischen Registries nach dem Start eingefroren
Dimension.values();
MinecraftServer.setBrandName("mhsl.eu - minenet - credits to minestom");
MinecraftServer.setCompressionThreshold(serverConfig.node("compression-threshold").getInt(0));
System.setProperty("minestom.chunk-view-distance", String.valueOf(serverConfig.node("view-distance").getInt()));
Commands.values();
Listeners.values();
@@ -57,12 +74,6 @@ public class Main {
if(serverConfig.node("open-to-lan").getBoolean()) OpenToLAN.open();
if(globalConfig.node("bungeecord", "enabled").getBoolean()) BungeeCordProxy.enable();
if(globalConfig.node("velocity", "enabled").getBoolean()) {
VelocityProxy.enable(Objects.requireNonNull(globalConfig.node("velocity", "secret").getString()));
}
server.start("0.0.0.0", serverConfig.node("port").getInt(25565));
System.gc();
@@ -35,7 +35,7 @@ public class SetMemorialCommand extends PrivilegedCommand {
.getTournament()
.setMemorialConfiguration(
new MemorialConfiguration(
Material.fromNamespaceId(context.get(materialArgument)),
Material.fromKey(context.get(materialArgument)),
context.get(titleArgument),
context.get(loreArgument)
)
@@ -37,7 +37,7 @@ public class SetRewardCommand extends PrivilegedCommand {
.getTournament()
.setRewardConfiguration(
new RewardConfiguration(
Material.fromNamespaceId(context.get(materialArgument)),
Material.fromKey(context.get(materialArgument)),
Arrays.stream(context.get(amountsArgument)).map(Integer::valueOf).collect(Collectors.toList())
)
);
@@ -2,6 +2,7 @@ package eu.mhsl.minenet.minigames.handler;
import eu.mhsl.minenet.minigames.handler.global.AddEntityToInstanceEventListener;
import eu.mhsl.minenet.minigames.handler.global.ChatFormatHandler;
import eu.mhsl.minenet.minigames.handler.global.PlayerFreezeHandler;
import eu.mhsl.minenet.minigames.handler.global.PlayerLeaveHandler;
import eu.mhsl.minenet.minigames.handler.global.PlayerLoginHandler;
import net.minestom.server.MinecraftServer;
@@ -11,6 +12,7 @@ public enum Listeners {
SPAWN(new AddEntityToInstanceEventListener()),
LOGIN(new PlayerLoginHandler()),
LEAVE(new PlayerLeaveHandler()),
FREEZE(new PlayerFreezeHandler()),
CHAT(new ChatFormatHandler());
Listeners(EventListener<?> event) {
@@ -2,6 +2,8 @@ package eu.mhsl.minenet.minigames.handler.global;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.event.EventListener;
import net.minestom.server.event.player.PlayerChatEvent;
import org.jetbrains.annotations.NotNull;
@@ -14,7 +16,14 @@ public class ChatFormatHandler implements EventListener<PlayerChatEvent> {
@Override
public @NotNull Result run(@NotNull PlayerChatEvent event) {
event.setFormattedMessage(new ChatMessage(Icon.CHAT).appendStatic(event.getRawMessage()).build(event.getPlayer()));
NamedTextColor color = event.getPlayer().getPermissionLevel() > 0 ? NamedTextColor.AQUA : NamedTextColor.GRAY;
event.setFormattedMessage(
new ChatMessage(Icon.CHAT)
.appendStatic(Component.text(event.getPlayer().getUsername(), color))
.appendStatic(Component.text(" > ", NamedTextColor.DARK_GRAY))
.appendStatic(event.getRawMessage())
.build(event.getPlayer())
);
return Result.SUCCESS;
}
}
@@ -0,0 +1,43 @@
package eu.mhsl.minenet.minigames.handler.global;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
import net.minestom.server.event.EventListener;
import net.minestom.server.event.player.PlayerTickEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
/**
* Weder Minestom noch MinestomPvP verwalten die TICKS_FROZEN-Metadata. Der 26.x-Client baut das
* Freeze-Overlay bei Powder-Snow-Kontakt zwar selbst auf, der Abbau ist aber server-authoritative —
* ohne diese Vanilla-Logik bleibt das Overlay dauerhaft haengen (auch ueber Instanzwechsel hinweg).
*/
public class PlayerFreezeHandler implements EventListener<PlayerTickEvent> {
private static final int TICKS_REQUIRED_TO_FREEZE = 140;
@Override
public @NotNull Class<PlayerTickEvent> eventType() {
return PlayerTickEvent.class;
}
@Override
public @NotNull Result run(@NotNull PlayerTickEvent event) {
Player p = event.getPlayer();
Instance instance = p.getInstance();
if(instance == null) return Result.SUCCESS;
Pos pos = p.getPosition();
boolean inPowderSnow = instance.getBlock(pos).compare(Block.POWDER_SNOW)
|| instance.getBlock(pos.add(0, 1, 0)).compare(Block.POWDER_SNOW);
int ticksFrozen = p.getEntityMeta().getTickFrozen();
if(inPowderSnow) {
p.getEntityMeta().setTickFrozen(Math.min(TICKS_REQUIRED_TO_FREEZE, ticksFrozen + 1));
} else if(ticksFrozen > 0) {
p.getEntityMeta().setTickFrozen(Math.max(0, ticksFrozen - 2));
}
return Result.SUCCESS;
}
}
@@ -1,45 +1,81 @@
package eu.mhsl.minenet.minigames.instance;
import net.kyori.adventure.key.Key;
import net.minestom.server.MinecraftServer;
import net.minestom.server.registry.DynamicRegistry;
import net.minestom.server.utils.NamespaceID;
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;
import net.minestom.server.world.timeline.Timeline;
/**
* Prebuilt dimensions
*/
public enum Dimension {
OVERWORLD(
NamespaceID.from("minenet:fullbright_overworld"),
Key.key("minenet:fullbright_overworld"),
DimensionType
.builder()
.ambientLight(2.0f)
// Fullbright: seit MC 26.1 laeuft die Grundhelligkeit ueber das Environment-Attribut
// AMBIENT_LIGHT_COLOR (Vanilla-Overworld: #0A0A0A); der alte ambientLight-2.0-Hack
// liegt ausserhalb der validen Range und bricht das Client-Rendering
.setAttribute(EnvironmentAttribute.AMBIENT_LIGHT_COLOR, NamedTextColor.WHITE)
// defaultClock noetig, damit Instance#setTime/#defaultClock funktionieren (sonst null); Timelines wie Vanilla-Overworld
.defaultClock(WorldClock.OVERWORLD)
.timelines(RegistryTag.direct(Timeline.DAY, Timeline.MOON, Timeline.EARLY_GAME, Timeline.VILLAGER_SCHEDULE))
.build()
),
NETHER(
NamespaceID.from("minenet:fullbright_nether"),
Key.key("minenet:fullbright_nether"),
DimensionType
.builder()
.ambientLight(2.0f)
.effects("minecraft:the_nether")
// Fullbright: seit MC 26.1 laeuft die Grundhelligkeit ueber das Environment-Attribut
// AMBIENT_LIGHT_COLOR (Vanilla-Overworld: #0A0A0A); der alte ambientLight-2.0-Hack
// liegt ausserhalb der validen Range und bricht das Client-Rendering
.setAttribute(EnvironmentAttribute.AMBIENT_LIGHT_COLOR, NamedTextColor.WHITE)
.skybox(DimensionType.Skybox.NONE)
.defaultClock(WorldClock.OVERWORLD)
.timelines(RegistryTag.direct(Timeline.VILLAGER_SCHEDULE))
.build()
),
THE_END(
NamespaceID.from("minenet:fullbright_end"),
Key.key("minenet:fullbright_end"),
DimensionType
.builder()
.ambientLight(2.0f)
.effects("minecraft:the_end")
// Fullbright: seit MC 26.1 laeuft die Grundhelligkeit ueber das Environment-Attribut
// AMBIENT_LIGHT_COLOR (Vanilla-Overworld: #0A0A0A); der alte ambientLight-2.0-Hack
// liegt ausserhalb der validen Range und bricht das Client-Rendering
.setAttribute(EnvironmentAttribute.AMBIENT_LIGHT_COLOR, NamedTextColor.WHITE)
.skybox(DimensionType.Skybox.END)
.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;
public final NamespaceID namespaceID;
public final DynamicRegistry.Key<DimensionType> key;
public final Key namespaceID;
public final RegistryKey<DimensionType> key;
Dimension(NamespaceID namespaceID, DimensionType dimType) {
Dimension(Key namespaceID, DimensionType dimType) {
this.DIMENSION = dimType;
this.namespaceID = namespaceID;
@@ -1,21 +1,29 @@
package eu.mhsl.minenet.minigames.instance;
import eu.mhsl.minenet.minigames.message.component.TranslatedComponent;
import net.kyori.adventure.key.Key;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import net.minestom.server.event.instance.AddEntityToInstanceEvent;
import net.minestom.server.event.instance.RemoveEntityFromInstanceEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.registry.DynamicRegistry;
import net.minestom.server.registry.RegistryKey;
import net.minestom.server.timer.TaskSchedule;
import net.minestom.server.world.DimensionType;
import java.util.UUID;
public class MineNetInstance extends InstanceContainer {
public MineNetInstance(DynamicRegistry.Key<DimensionType> type) {
super(UUID.randomUUID(), type);
public MineNetInstance(RegistryKey<DimensionType> type) {
this(UUID.randomUUID(), type);
}
// Eindeutiger Dimension-Name pro Instanz: Minestom entscheidet anhand dieses Namens (nicht des
// DimensionTypes), ob beim setInstance das volle Respawn-Protokoll gefahren wird. Ohne den Reset
// rendert der 26.x-Client nach einem Wechsel zwischen Instanzen gleicher Dimension keine Chunks.
private MineNetInstance(UUID uuid, RegistryKey<DimensionType> type) {
super(uuid, type, Key.key("minenet", "instance-" + uuid));
MinecraftServer.getInstanceManager().registerInstance(this);
this.eventNode()
@@ -16,7 +16,7 @@ import net.minestom.server.event.item.ItemDropEvent;
import net.minestom.server.event.player.PlayerBlockBreakEvent;
import net.minestom.server.event.player.PlayerBlockPlaceEvent;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.registry.DynamicRegistry;
import net.minestom.server.registry.RegistryKey;
import net.minestom.server.timer.ExecutionType;
import net.minestom.server.timer.TaskSchedule;
import net.minestom.server.world.DimensionType;
@@ -34,7 +34,7 @@ public abstract class Game extends MineNetInstance implements Spawnable {
protected boolean isRunning = false;
protected boolean isBeforeBeginning = true;
public Game(DynamicRegistry.Key<DimensionType> dimensionType) {
public Game(RegistryKey<DimensionType> dimensionType) {
super(dimensionType);
MinecraftServer.getInstanceManager().registerInstance(this);
@@ -5,15 +5,19 @@ import eu.mhsl.minenet.minigames.instance.game.stateless.types.acidRain.AcidRain
import eu.mhsl.minenet.minigames.instance.game.stateless.types.anvilRun.AnvilRunFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.backrooms.BackroomsFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.bedwars.BedwarsFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.blockBattle.BlockBattleFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.blockBreakRace.BlockBreakRaceFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.boatRace.BoatRaceFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.bowSpleef.BowSpleefFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.colorJump.ColorJumpFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.deathcube.DeathcubeFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.elytraRace.ElytraRaceFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.fastbridge.FastbridgeFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.highGround.HighGroundFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.jumpDive.JumpDiveFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.minerun.MinerunFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.pillars.PillarsFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.PlayerRaceFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.spaceSnake.SpaceSnakeFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.spleef.SpleefFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.stickfight.StickFightFactory;
@@ -31,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),
@@ -46,7 +50,11 @@ public enum GameList {
FASTBRIDGE(new FastbridgeFactory(), GameType.OTHER),
BLOCKBREAKRACE(new BlockBreakRaceFactory(), GameType.OTHER),
SPACESNAKE(new SpaceSnakeFactory(), GameType.PVP),
BOATRACE(new BoatRaceFactory(), GameType.OTHER);
BOATRACE(new BoatRaceFactory(), GameType.OTHER),
PILLARS(new PillarsFactory(), GameType.PROTOTYPE),
BLOCKBATTLE(new BlockBattleFactory(), GameType.PVP),
COLORJUMP(new ColorJumpFactory(), GameType.JUMPNRUN),
PLAYERRACE(new PlayerRaceFactory(), GameType.JUMPNRUN);
private final GameFactory factory;
private final GameType type;
@@ -9,7 +9,7 @@ import eu.mhsl.minenet.minigames.score.Score;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.registry.DynamicRegistry;
import net.minestom.server.registry.RegistryKey;
import net.minestom.server.timer.ExecutionType;
import net.minestom.server.timer.Task;
import net.minestom.server.timer.TaskSchedule;
@@ -26,7 +26,7 @@ public class StatelessGame extends Game {
private int timeLimit = 0;
private int timePlayed = 0;
public StatelessGame(DynamicRegistry.Key<DimensionType> dimensionType, String gameName, Score score) {
public StatelessGame(RegistryKey<DimensionType> dimensionType, String gameName, Score score) {
super(dimensionType);
this.score = score;
this.name = gameName;
@@ -14,9 +14,9 @@ import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import net.minestom.server.event.instance.AddEntityToInstanceEvent;
import net.minestom.server.event.instance.RemoveEntityFromInstanceEvent;
import net.minestom.server.event.inventory.InventoryPreClickEvent;
import net.minestom.server.inventory.InventoryType;
import net.minestom.server.inventory.click.ClickType;
import net.minestom.server.inventory.condition.InventoryConditionResult;
import net.minestom.server.inventory.click.Click;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import net.minestom.server.sound.SoundEvent;
@@ -106,8 +106,8 @@ public class GameConfigurationInventory extends InteractableInventory {
}
@Override
protected void onClick(Player player, int slot, ClickType clickType, InventoryConditionResult inventoryConditionResult) {
inventoryConditionResult.setCancel(true);
protected void onClick(Player player, int slot, Click click, InventoryPreClickEvent event) {
event.setCancelled(true);
if(!this.map.containsKey(slot)) return;
@@ -1,6 +1,7 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.acidRain;
import de.articdive.jnoise.JNoise;
import de.articdive.jnoise.generators.noisegen.opensimplex.FastSimplexNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import eu.mhsl.minenet.minigames.instance.Dimension;
import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame;
import eu.mhsl.minenet.minigames.score.LastWinsScore;
@@ -28,9 +29,8 @@ import java.util.concurrent.CompletableFuture;
public class AcidRain extends StatelessGame {
final JNoise noise = JNoise.newBuilder()
.fastSimplex()
.setSeed(this.rnd.nextLong())
.setFrequency(0.09)
.fastSimplex(FastSimplexNoiseGenerator.newBuilder().setSeed(this.rnd.nextLong()).build())
.scale(0.09)
.build();
final private int radius = 20;
final private int roofHeight = 55;
@@ -105,7 +105,7 @@ public class AcidRain extends StatelessGame {
for(int z = -radius; z <= radius; z++) {
batch.setBlock(x, this.roofHeight, z, Block.AIR);
if(new Pos(x, 0, z).distance(new Pos(0, 0, 0)) > radius) continue;
if(this.noise.getNoise(x + offset, z + offset) > 0.4) continue;
if(this.noise.evaluateNoise(x + offset, z + offset) > 0.4) continue;
batch.setBlock(x, this.roofHeight, z, Block.OAK_PLANKS);
}
@@ -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);
}
}
@@ -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<String, Option<?>> 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);
}
}
@@ -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<Supplier<LevelDefinition>> LEVEL_POOL = List.of(
LevelHabitableZone::new,
LevelPipeDreams::new,
LevelPoolrooms::new,
LevelLightsOut::new,
LevelFun::new
);
private final List<LevelRuntime> levels = new ArrayList<>();
private final Map<UUID, BackroomsPlayer> playerStates = new HashMap<>();
private final Map<UUID, Sidebar> 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<Integer> 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<LevelDefinition> 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<LevelDefinition> buildSequence(int totalLevels) {
List<LevelDefinition> sequence = new ArrayList<>();
sequence.add(new Level0Lobby());
List<Supplier<LevelDefinition>> 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<LevelRuntime> getLevels() {
return this.levels;
}
public Random rnd() {
return this.rnd;
}
public BackroomsPlayer playerState(Player player) {
return this.playerStates.get(player.getUuid());
}
public List<Player> 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<Void> 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<Player> 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<Player> 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();
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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<Player> p, int points) {
this.getScores().add(this.escapedCount, p);
this.escapedCount++;
}
/** Tod bzw. verbliebene Spieler bei Spielende */
@Override
protected void insertResultImplementation(Set<Player> 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 &gt;= 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");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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()
));
}
}
}
@@ -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<SoundEvent> oneShots,
double oneShotChancePerSecond
) {
}
@@ -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<SoundEvent> 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);
}
}
@@ -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);
}
}
@@ -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<LevelRuntime> levels;
/** O(1)-Lookups pro Level (statt Listen-Scans pro Blocksaeule) */
private final List<Set<Long>> lightKeys = new ArrayList<>();
private final List<Set<Long>> chestKeys = new ArrayList<>();
/** wiederverwendetes Random pro Generator-Thread (Seed pro Saeule gesetzt) */
private static final ThreadLocal<Random> COLUMN_RANDOM = ThreadLocal.withInitial(Random::new);
public BackroomsChunkGenerator(List<LevelRuntime> levels) {
this.levels = levels;
for(LevelRuntime level : levels) {
Set<Long> lights = new HashSet<>();
level.getLayout().getLightCells().forEach(cell -> lights.add(cellKey(cell[0], cell[1])));
this.lightKeys.add(lights);
Set<Long> 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);
}
}
@@ -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<int[]> 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<int[]> 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<int[]> 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<int[]> 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<int[]> 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<int[]> 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<int[]> 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<int[]> queue = new ArrayDeque<>();
java.util.Map<Long, int[]> 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<int[]> 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<int[]> sampleSpread(List<int[]> candidates, Random rnd, int count, double minDistance) {
List<int[]> pool = new ArrayList<>(candidates);
Collections.shuffle(pool, rnd);
List<int[]> 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;
}
}
@@ -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<String> ITEM_TAG = Tag.String("backroomsItem");
public static final Tag<Integer> 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<ItemStack> 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<ItemStack> 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));
}
}
@@ -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<int[]> 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<int[]> 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<int[]> 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<int[]> 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<int[]> 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<int[]> valid = new ArrayList<>();
List<int[]> 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<int[]> 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<int[]> 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<int[]> 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};
}
}
@@ -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<int[]> spawnCells = new ArrayList<>();
private final List<int[]> chestCells = new ArrayList<>();
private final List<int[]> lightCells = new ArrayList<>();
/** frei nutzbare Level-Marker (z. B. Dampf-Ventile in den Poolrooms) */
private final List<int[]> 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<int[]> getSpawnCells() {
return this.spawnCells;
}
public List<int[]> getChestCells() {
return this.chestCells;
}
public List<int[]> getLightCells() {
return this.lightCells;
}
public List<int[]> 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()));
}
}
@@ -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<Block> floor,
List<Block> wall,
List<Block> ceiling,
Block light,
Block pillar,
int roomHeight,
Block wallBase,
boolean checkerFloor,
List<Block> floorDeco,
double floorDecoChance,
List<Block> 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()));
}
}
@@ -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<BackroomsMonster> 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<BackroomsMonster> getMonsters() {
return this.monsters;
}
public boolean isBlackoutActive() {
return this.blackoutActive;
}
public void setBlackoutActive(boolean blackoutActive) {
this.blackoutActive = blackoutActive;
}
public List<Player> 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));
}
}
@@ -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;
}
}
@@ -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
}
@@ -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 (&gt; 10 Zellen) zu allen Spawns;
* der Abstand wird schrittweise gesenkt, falls sich sonst zu wenige Zellen finden.
*/
static List<int[]> 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<int[]> far(LevelLayout layout, Random rnd, int count, BiPredicate<Integer, Integer> filter) {
for(int minDistance = MONSTER_MIN_SPAWN_DISTANCE; ; minDistance -= 2) {
List<int[]> 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<int[]> 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]));
}
}
@@ -0,0 +1,151 @@
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.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;
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 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();
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);
}
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, 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);
int roll = runtime.getRnd().nextInt(100);
if(roll < 20) return new Hound(runtime);
if(roll < 35) return new Bacteria(runtime);
return 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
);
}
}
@@ -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<int[]> challengeRooms = new ArrayList<>();
private final Map<UUID, Integer> challengeTimers = new HashMap<>();
private final Map<UUID, Smiler> 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<int[]> 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<int[]> 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
);
}
}
@@ -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
);
}
}
@@ -0,0 +1,146 @@
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.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;
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);
}
// 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, dazu Wretches und getarnte Bacteria. */
@Override
public BackroomsMonster createRoamingMonster(LevelRuntime runtime, int[] cell) {
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. */
@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
);
}
}
@@ -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
);
}
}
@@ -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<int[]> 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<int[]> 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<int[]> 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
);
}
}
@@ -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<Player> 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
);
}
}
@@ -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<Point, Integer> chestLevels = new HashMap<>();
private final Map<Point, Integer> keyChestLevels = new HashMap<>();
private final Map<Point, Set<UUID>> keyChestLooters = new HashMap<>();
private final Map<Point, Set<UUID>> 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<UUID> 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<UUID> 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<ItemStack> 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<ItemStack> rollFor(Player player, int levelIndex) {
List<ItemStack> 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)));
}
}
@@ -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<ItemStack> item, int weight) {
}
private final List<Entry> entries = new ArrayList<>();
private int totalWeight = 0;
public LootTable add(Supplier<ItemStack> item, int weight) {
this.entries.add(new Entry(item, weight));
this.totalWeight += weight;
return this;
}
public List<ItemStack> roll(Random rnd) {
List<ItemStack> 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;
}
}
@@ -0,0 +1,207 @@
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<CompletableFuture<Chunk>> 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);
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;
/** 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<Player> 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())
);
}
}
@@ -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);
}
}
@@ -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<Player> 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);
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
}
@@ -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 (minecraft-heads.com #74583). */
public static final String SMILER_HEAD =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvODc2ZmViODIxZTVkYTE1NTRiODkzZmM1MzY1ZjdiN2E5ODY3M2QxZjliNzU3OTZiODdmNzRlM2M5NjFjMGU3OCJ9fX0=";
/** 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();
}
}
@@ -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);
}
}
}
@@ -0,0 +1,132 @@
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.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.AbstractDisplayMeta;
import net.minestom.server.entity.metadata.display.ItemDisplayMeta;
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 {
/** 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;
this.attackDamage = 4;
this.ignoresDarkness = true;
this.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.11);
this.setInvisible(true);
}
/**
* 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
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;
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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];
}
}
@@ -26,6 +26,7 @@ import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import net.minestom.server.timer.ExecutionType;
import net.minestom.server.timer.TaskSchedule;
import net.minestom.server.world.DimensionType;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
@@ -41,7 +42,7 @@ public class Bedwars extends StatelessGame {
public Bedwars() throws IOException {
super(Dimension.OVERWORLD.key, "Bedwars", new LastWinsScore());
this.setChunkLoader(new AnvilLoader(Resource.GAME_MAP.getPath().resolve("bedwars/test")));
this.setChunkLoader(new AnvilLoader(Resource.GAME_MAP.getPath().resolve("bedwars/test"), DimensionType.OVERWORLD.key()));
Configuration config = ConfigurationProvider.getProvider(YamlConfiguration.class).load(Resource.GAME_MAP.getPath().resolve("bedwars/test/config.yml").toFile());
List<String> teamNames = config.getStringList("setup.teams");
@@ -0,0 +1,207 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.blockBattle;
import eu.mhsl.minenet.minigames.instance.Dimension;
import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame;
import eu.mhsl.minenet.minigames.score.FirstWinsScore;
import eu.mhsl.minenet.minigames.util.BatchUtil;
import io.github.togar2.pvp.events.FinalAttackEvent;
import io.github.togar2.pvp.feature.CombatFeatures;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.GameMode;
import net.minestom.server.entity.Player;
import net.minestom.server.event.player.PlayerBlockBreakEvent;
import net.minestom.server.event.player.PlayerBlockPlaceEvent;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.instance.batch.AbsoluteBlockBatch;
import net.minestom.server.instance.block.Block;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
public class BlockBattle extends StatelessGame {
private final Team teamBlue = new Team(new Pos(0,101,20).add(0.5).withView(180, 0), Team.Color.BLUE);
private final Team teamRed = new Team(new Pos(0, 101, -20).add(0.5), Team.Color.RED);
private final int itemCount;
private final Map<Player, Team> teams = new WeakHashMap<>();
public BlockBattle(int itemCount) {
super(Dimension.THE_END.key, "Block Battle", new FirstWinsScore());
this.itemCount = itemCount;
this.eventNode().addChild(
CombatFeatures.empty()
.add(CombatFeatures.VANILLA_ATTACK)
.add(CombatFeatures.VANILLA_DAMAGE)
.add(CombatFeatures.VANILLA_KNOCKBACK)
.build().createNode()
);
this.eventNode().addListener(FinalAttackEvent.class, finalAttackEvent -> {
if(this.isBeforeBeginning) finalAttackEvent.setCancelled(true);
finalAttackEvent.setBaseDamage(0);
((Player) finalAttackEvent.getTarget()).setHealth(20);
});
}
@Override
protected void onLoad(@NotNull CompletableFuture<Void> callback) {
this.generatePlatform(new Pos(0, 100, 0), Block.GOLD_BLOCK, Block.YELLOW_CONCRETE_POWDER);
this.generatePlatform(new Pos(0, 101, 0), Block.AIR, Block.SANDSTONE_SLAB);
this.generatePlatform(new Pos(0, 100, 20), Block.BLUE_CONCRETE, Block.BLUE_CONCRETE_POWDER);
this.generatePlatform(new Pos(0, 100, -20), Block.RED_CONCRETE, Block.RED_CONCRETE_POWDER);
this.generatePlatform(new Pos(-5, 101, -14), Block.RED_STAINED_GLASS, Block.AIR);
this.generatePlatform(new Pos(5, 101, 14), Block.BLUE_STAINED_GLASS, Block.AIR);
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
Pos[] positionsRedGlass = {
new Pos(2, 102, -9),
new Pos(-1, 103, -9),
new Pos(-2, 104, -6),
new Pos(-5, 103, -7),
new Pos(-7, 102, -10),
new Pos(3, 102, -12),
new Pos(5, 101, -15)
};
Pos[] positionsBlueGlass = {
new Pos(-5, 101, 15),
new Pos(-3, 102, 12),
new Pos(-2, 102, 9),
new Pos(1, 103, 9),
new Pos(2, 104, 6),
new Pos(5, 103, 7),
new Pos(7, 102, 10)
};
for(Pos pos : positionsRedGlass)
batch.setBlock(pos, Block.RED_STAINED_GLASS);
for(Pos pos : positionsBlueGlass)
batch.setBlock(pos, Block.BLUE_STAINED_GLASS);
BatchUtil.loadAndApplyBatch(batch, this, () -> {});
}
@Override
protected void onBlockPlace(@NotNull PlayerBlockPlaceEvent playerBlockPlaceEvent) {
Pos posGoldBlock = new Pos(playerBlockPlaceEvent.getBlockPosition()).sub(0, 1, 0);
Block goldBlock = playerBlockPlaceEvent.getInstance().getBlock(posGoldBlock);
playerBlockPlaceEvent.setCancelled(goldBlock != Block.GOLD_BLOCK);
playerBlockPlaceEvent.getInstance().scheduler().scheduleNextTick(() -> {
Pos middle = new Pos(0, 101, 0);
boolean validBlue = true;
boolean validRed = true;
for(int x = middle.blockX()-1; x < middle.blockX()+2; x++) {
for(int z = middle.blockZ()-1; z < middle.blockZ()+2; z++) {
if(playerBlockPlaceEvent.getInstance().getBlock(x, 101, z) != Block.BLUE_WOOL) {
validBlue = false;
break;
}
}
if(!validBlue)
break;
}
for(int x = middle.blockX()-1; x < middle.blockX()+2; x++) {
for(int z = middle.blockZ()-1; z < middle.blockZ()+2; z++) {
if(playerBlockPlaceEvent.getInstance().getBlock(x, 101, z) != Block.RED_WOOL) {
validRed = false;
break;
}
}
if(!validRed)
break;
}
if(!validBlue && !validRed) return;
var winningTeam = validBlue ? Team.Color.BLUE : Team.Color.RED;
var winningPlayers = this.teams.entrySet().stream()
.filter(entry -> entry.getValue().color().equals(winningTeam))
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
this.getScore().insertMultiple(winningPlayers);
super.stop();
});
}
@Override
protected void onBlockBreak(@NotNull PlayerBlockBreakEvent playerBlockBreakEvent) {
boolean isAllowed = Arrays.stream(Team.Color.values())
.map(Team.Color::getMaterial)
.map(Material::block)
.toList()
.contains(playerBlockBreakEvent.getBlock());
if(!isAllowed) playerBlockBreakEvent.setCancelled(true);
}
@Override
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
if(playerMoveEvent.getNewPosition().y() < 95) {
var player = playerMoveEvent.getPlayer();
player.teleport(
this.isBeforeBeginning
? this.getSpawn()
: this.teams.get(player).spawnPosition()
);
this.giveItems(player);
}
}
@Override
protected void onStart() {
this.setTeams();
this.getPlayers().forEach(player -> player.teleport(this.teams.get(player).spawnPosition()).thenRun(() -> {
this.giveItems(player);
player.setGameMode(GameMode.SURVIVAL);
}));
}
private void generatePlatform(Pos center, Block inner, Block outer) {
for(int x = center.blockX()-2; x < center.blockX()+3; x++) {
for(int z = center.blockZ()-2; z < center.blockZ()+3; z++) {
this.setBlock(x, center.blockY(), z, outer);
}
}
for(int x = center.blockX()-1; x < center.blockX()+2; x++) {
for(int z = center.blockZ()-1; z < center.blockZ()+2; z++) {
this.setBlock(x, center.blockY(), z, inner);
}
}
}
private void setTeams() {
List<Player> players = new ArrayList<>(this.getPlayers());
Collections.shuffle(players);
int halfPlayers = players.size()/2;
players.subList(0, halfPlayers).forEach(player -> this.teams.put(player, this.teamBlue));
players.subList(halfPlayers, players.size()).forEach(player -> this.teams.put(player, this.teamRed));
}
@Override
public Pos getSpawn() {
return new Pos(0, 101, 0).add(0.5);
}
private void giveItems(Player player) {
player.getInventory().clear();
ItemStack item = ItemStack.of(
this.teams.get(player).color().getMaterial(),
this.itemCount
);
player.getInventory().addItemStack(item);
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.blockBattle;
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 BlockBattleFactory implements GameFactory {
@Override
public TranslatedComponent name() {
return TranslatedComponent.byId("game_BlockBattle#name");
}
@Override
public TranslatedComponent description() {
return TranslatedComponent.byId("game_BlockBattle#description");
}
@Override
public ConfigManager configuration() {
return new ConfigManager()
.addOption(new NumericOption("itemCount", Material.WHITE_WOOL, TranslatedComponent.byId("game_BlockBattle#itemCount"), 1, 2, 3));
}
@Override
public Material symbol() {
return Material.GREEN_CONCRETE;
}
@Override
public Game manufacture(Room parent, Map<String, Option<?>> configuration) throws Exception {
return new BlockBattle(configuration.get("itemCount").getAsInt()).setParent(parent);
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.blockBattle;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.item.Material;
public record Team(Pos spawnPosition, Color color) {
public enum Color {
RED(Material.RED_WOOL),
BLUE(Material.BLUE_WOOL);
private final Material block;
Color(Material block) {
this.block = block;
}
public Material getMaterial() {
return this.block;
}
}
}
@@ -15,6 +15,7 @@ import net.minestom.server.entity.Player;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.instance.anvil.AnvilLoader;
import net.minestom.server.instance.block.Block;
import net.minestom.server.world.DimensionType;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.CompletableFuture;
@@ -23,7 +24,7 @@ import java.util.concurrent.CompletableFuture;
public class BoatRace extends StatelessGame {
public BoatRace() {
super(Dimension.OVERWORLD.key, "boatRace", new FirstWinsScore());
this.setChunkLoader(new AnvilLoader(Resource.GAME_MAP.getPath().resolve("boatRace/woodlandMansion")));
this.setChunkLoader(new AnvilLoader(Resource.GAME_MAP.getPath().resolve("boatRace/woodlandMansion"), DimensionType.OVERWORLD.key()));
}
@Override
@@ -0,0 +1,176 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.colorJump;
import eu.mhsl.minenet.minigames.instance.Dimension;
import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame;
import eu.mhsl.minenet.minigames.score.LastWinsScore;
import eu.mhsl.minenet.minigames.util.BatchUtil;
import eu.mhsl.minenet.minigames.world.generator.terrain.CircularPlateTerrainGenerator;
import net.minestom.server.MinecraftServer;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.GameMode;
import net.minestom.server.event.inventory.InventoryPreClickEvent;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.event.player.PlayerSwapItemEvent;
import net.minestom.server.instance.batch.AbsoluteBlockBatch;
import net.minestom.server.instance.block.Block;
import net.minestom.server.item.ItemStack;
import net.minestom.server.timer.Scheduler;
import net.minestom.server.timer.TaskSchedule;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
public class ColorJump extends StatelessGame {
private final List<Block> blocks = List.of(
Block.RED_CONCRETE,
Block.ORANGE_CONCRETE,
Block.YELLOW_CONCRETE,
Block.LIME_CONCRETE,
Block.LIGHT_BLUE_CONCRETE,
Block.BLUE_CONCRETE,
Block.PURPLE_CONCRETE,
Block.PINK_CONCRETE
);
private Block currentBlock;
private Block blockLastRound;
private double roundTime = 5;
private final double multiplierNextRoundTime = 0.9;
public ColorJump() {
super(Dimension.THE_END.key, "ColorJump", new LastWinsScore());
this.getScore().setIgnoreLastPlayers(1);
this.setGenerator(new CircularPlateTerrainGenerator(100));
this.eventNode().addListener(
InventoryPreClickEvent.class,
inventoryPreClickEvent -> inventoryPreClickEvent.setCancelled(true)
);
this.eventNode().addListener(
PlayerSwapItemEvent.class,
playerSwapItemEvent -> playerSwapItemEvent.setCancelled(true)
);
}
@Override
protected void onLoad(@NotNull CompletableFuture<Void> callback) {
this.generate();
}
@Override
protected void onStart() {
this.nextRound();
}
private void nextRound() {
this.generate();
do {
this.currentBlock = this.blocks.get(ThreadLocalRandom.current().nextInt(this.blocks.size()));
} while(this.currentBlock == this.blockLastRound);
this.blockLastRound = this.currentBlock;
ItemStack item = ItemStack.of(Objects.requireNonNull(this.currentBlock.registry().material()));
this.getPlayers().forEach(player -> {
player.getInventory().clear();
for(int i = 0; i < 9; i++) {
player.getInventory().setItemStack(i, item);
}
});
var scheduler = MinecraftServer.getSchedulerManager();
TaskSchedule stop = TaskSchedule.stop();
scheduler.scheduleTask(() -> {
if(!this.isRunning) return stop;
this.destroyAllExcept(this.currentBlock);
scheduler.scheduleTask(() -> {
if(this.isRunning) this.nextRound();
return stop;
}, TaskSchedule.seconds(3));
return stop;
}, TaskSchedule.seconds((long) this.roundTime));
this.roundTime = this.roundTime * this.multiplierNextRoundTime;
if(this.roundTime <= 1) this.roundTime = 1;
long totalTicks = (long) (this.roundTime * 20);
this.scheduleExpUpdate(scheduler, totalTicks, totalTicks);
}
private void scheduleExpUpdate(Scheduler scheduler, long remainingTicks, long totalTicks) {
if (!this.isRunning) return;
if (remainingTicks <= 0) {
this.getPlayers().forEach(player -> {
player.setExp(0f);
player.setLevel(0);
});
return;
}
float progress = (float) remainingTicks / totalTicks;
this.getPlayers().forEach(player -> {
player.setExp(progress);
player.setLevel((int) Math.ceil(remainingTicks / 20.0));
});
scheduler.scheduleTask(() -> {
this.scheduleExpUpdate(scheduler, remainingTicks -1, totalTicks);
return TaskSchedule.stop();
}, TaskSchedule.tick(1));
}
private void generate() {
int y = 30;
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
for (int x = -20; x <= 21; x += 2) {
for (int z = -11; z <= 10; z += 2) {
Block randomBlock = this.blocks.get(ThreadLocalRandom.current().nextInt(this.blocks.size()));
for (int dx = 0; dx < 2; dx++) {
for (int dz = 0; dz < 2; dz++) {
batch.setBlock(x + dx, y, z + dz, randomBlock);
}
}
}
}
BatchUtil.loadAndApplyBatch(batch, this, () -> {});
}
private void destroyAllExcept(Block block) {
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
int y = 30;
for (int x = -20; x <= 21; x++) {
for(int z = -11; z <= 10; z++) {
Pos blockPos = new Pos(x, y, z);
if(this.getBlock(blockPos) != block)
batch.setBlock(blockPos, Block.AIR);
}
}
BatchUtil.loadAndApplyBatch(batch, this, () -> {});
}
@Override
public Pos getSpawn() {
return new Pos(0, 31, 0);
}
@Override
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
var player = playerMoveEvent.getPlayer();
if(playerMoveEvent.getNewPosition().y() >= 29)
return;
player.teleport(this.getSpawn());
if(this.isRunning) {
this.getScore().insertResult(player);
player.setGameMode(GameMode.SPECTATOR);
}
}
}
@@ -0,0 +1,33 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.colorJump;
import eu.mhsl.minenet.minigames.instance.game.Game;
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.room.Room;
import eu.mhsl.minenet.minigames.message.component.TranslatedComponent;
import net.minestom.server.item.Material;
import java.util.Map;
public class ColorJumpFactory implements GameFactory {
@Override
public TranslatedComponent name() {
return TranslatedComponent.byId("game_ColorJump#name");
}
@Override
public Material symbol() {
return Material.PINK_CONCRETE;
}
@Override
public TranslatedComponent description() {
return TranslatedComponent.byId("game_ColorJump#description");
}
@Override
public Game manufacture(Room parent, Map<String, Option<?>> configuration) throws Exception {
return new ColorJump().setParent(parent);
}
}
@@ -24,6 +24,7 @@ import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.event.player.PlayerStartFlyingWithElytraEvent;
import net.minestom.server.event.player.PlayerStopFlyingWithElytraEvent;
import net.minestom.server.event.player.PlayerUseItemEvent;
import net.minestom.server.instance.Chunk;
import net.minestom.server.instance.batch.AbsoluteBlockBatch;
import net.minestom.server.instance.block.Block;
import net.minestom.server.item.ItemStack;
@@ -133,7 +134,7 @@ public class ElytraRace extends StatelessGame {
return;
}
this.playerCheckpoints.putIfAbsent(player, new CheckPointData(this.ringSpacing, this.ringSpacing * 2));
this.playerCheckpoints.putIfAbsent(player, new CheckPointData(0, this.ringSpacing));
if(newPos.z() > this.generatedUntil - this.ringSpacing) {
this.generateRing(this.generatedUntil + this.ringSpacing);
@@ -209,7 +210,16 @@ public class ElytraRace extends StatelessGame {
point -> batch.setBlock(point, Block.AIR)
);
int minChunkX = (ringPos.blockX() - 100) >> 4;
int maxChunkX = (ringPos.blockX() + 100) >> 4;
int chunkZ = ringPos.blockZ() >> 4;
BatchUtil.loadAndApplyBatch(batch, this, () -> {
for(int cx = minChunkX; cx <= maxChunkX; cx++) {
Chunk chunk = this.getChunk(cx, chunkZ);
if(chunk == null) continue;
this.getPlayers().forEach(chunk::sendChunk);
}
});
}
@@ -224,10 +234,12 @@ public class ElytraRace extends StatelessGame {
}
private void toCheckpoint(Player p) {
CheckPointData data = this.playerCheckpoints.get(p);
Point checkpointPos = this.getRingPositionAtZ(this.playerCheckpoints.get(p).currentCheckpoint);
if(data.currentCheckpoint == 0) checkpointPos = this.getSpawn().add(0, 3, 0);
p.setVelocity(Vec.ZERO);
p.setFlyingWithElytra(false);
p.teleport(Pos.fromPoint(checkpointPos).add(0.5, 0, 0.5));
p.teleport(new Pos(checkpointPos).add(0.5, 0, 0.5));
p.setFlying(true);
p.setFlyingSpeed(0);
@@ -8,6 +8,7 @@ import io.github.togar2.pvp.events.EntityKnockbackEvent;
import io.github.togar2.pvp.events.FinalAttackEvent;
import io.github.togar2.pvp.events.PrepareAttackEvent;
import io.github.togar2.pvp.feature.CombatFeatures;
import io.github.togar2.pvp.feature.knockback.KnockbackSettings;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
import net.minestom.server.event.instance.InstanceTickEvent;
@@ -50,7 +51,15 @@ class HighGround extends StatelessGame {
this.eventNode().addListener(
EntityKnockbackEvent.class,
entityKnockbackEvent -> entityKnockbackEvent.setStrength(1.1f)
entityKnockbackEvent -> entityKnockbackEvent.setSettings(
KnockbackSettings.builder()
.horizontal(1.1)
.vertical(KnockbackSettings.DEFAULT.vertical())
.verticalLimit(KnockbackSettings.DEFAULT.verticalLimit())
.extraHorizontal(KnockbackSettings.DEFAULT.extraHorizontal())
.extraVertical(KnockbackSettings.DEFAULT.extraVertical())
.build()
)
);
this.eventNode().addListener(InstanceTickEvent.class, instanceTickEvent -> {
@@ -0,0 +1,143 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.pillars;
import eu.mhsl.minenet.minigames.instance.Dimension;
import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame;
import eu.mhsl.minenet.minigames.score.LastWinsScore;
import eu.mhsl.minenet.minigames.util.Position;
import io.github.togar2.pvp.feature.CombatFeatures;
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.item.ItemDropEvent;
import net.minestom.server.event.player.PlayerBlockBreakEvent;
import net.minestom.server.event.player.PlayerBlockPlaceEvent;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.instance.block.Block;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import net.minestom.server.timer.TaskSchedule;
import org.jetbrains.annotations.NotNull;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
class Pillars extends StatelessGame {
private int spawnPosx = 0;
private int spawnPosz = 0;
private final int pillarSpacing = 10;
private final int pillarRowCount = 5;
private final int timeToNextItem = 5;
private static final Set<String> IGNORED_SUFFIXES = Set.of(
"_SWORD", "_PICKAXE", "_AXE", "_SHOVEL", "_HOE",
"_HELMET", "_CHESTPLATE", "_LEGGINGS", "_BOOTS",
"_BOAT", "_MINECART", "_BUCKET", "_SPAWN_EGG",
"_BANNER", "_CANDLE", "_PRESSURE_PLATE", "_TEMPLATE",
"_BUTTON", "_HARNESS"
);
private static final Set<Material> IGNORED_EXACT = Set.of(
Material.BOW, Material.CROSSBOW, Material.TRIDENT, Material.MACE, Material.SHIELD,
Material.SHEARS, Material.FLINT_AND_STEEL, Material.FISHING_ROD, Material.SADDLE,
Material.TURTLE_HELMET, Material.ELYTRA,
Material.LADDER, Material.VINE, Material.SCAFFOLDING, Material.TWISTING_VINES, Material.WEEPING_VINES,
Material.BAMBOO_RAFT,
Material.TNT, Material.SPAWNER, Material.END_PORTAL_FRAME,
Material.COMMAND_BLOCK, Material.CHAIN_COMMAND_BLOCK, Material.REPEATING_COMMAND_BLOCK,
Material.STRUCTURE_BLOCK, Material.STRUCTURE_VOID, Material.JIGSAW,
Material.BARRIER, Material.LIGHT, Material.DEBUG_STICK, Material.KNOWLEDGE_BOOK,
Material.BEDROCK, Material.AIR
);
private static final Set<Material> IGNORED_MATERIALS = new HashSet<>();
static {
for (Material material : Material.values()) {
String id = material.key().asString();
for (String suffix : IGNORED_SUFFIXES) {
if (id.endsWith(suffix.toLowerCase())) {
IGNORED_MATERIALS.add(material);
break;
}
}
}
}
public Pillars() {
super(Dimension.THE_END.key, "Pillars", new LastWinsScore());
this.getScore().setIgnoreLastPlayers(1);
this.eventNode().addChild(
CombatFeatures.empty()
.add(CombatFeatures.VANILLA_ATTACK)
.add(CombatFeatures.VANILLA_DAMAGE)
.add(CombatFeatures.VANILLA_KNOCKBACK)
.build().createNode()
);
}
@Override
protected boolean onPlayerJoin(Player p) {
Pos pos = new Pos(this.spawnPosx * this.pillarSpacing, 100, this.spawnPosz * this.pillarSpacing);
this.setBlock(pos.sub(0, 1, 0), Block.BEDROCK);
if(this.spawnPosx >= this.pillarRowCount) {
this.spawnPosx = 0;
this.spawnPosz++;
}
this.spawnPosx++;
MinecraftServer.getSchedulerManager().scheduleNextTick(() -> p.teleport(pos.add(0.5, 0, 0.5)));
return super.onPlayerJoin(p);
}
@Override
protected void onBlockPlace(@NotNull PlayerBlockPlaceEvent playerBlockPlaceEvent) {}
@Override
protected void onBlockBreak(@NotNull PlayerBlockBreakEvent playerBlockBreakEvent) {}
@Override
protected void onItemDrop(@NotNull ItemDropEvent itemDropEvent) {}
@Override
public Pos getSpawn() {
return new Pos(0, 105, 0);
}
@Override
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
var player = playerMoveEvent.getPlayer();
if(this.isBeforeBeginning && Position.hasPositionChanged(player.getPosition(), playerMoveEvent.getNewPosition()))
playerMoveEvent.setCancelled(true);
if(playerMoveEvent.getNewPosition().y() < 80) {
this.getScore().insertResult(player);
player.teleport(this.getSpawn());
player.setGameMode(GameMode.SPECTATOR);
}
}
@Override
protected void onStart() {
this.getPlayers().forEach(player -> player.setGameMode(GameMode.SURVIVAL));
MinecraftServer.getSchedulerManager().submitTask(() -> {
List<Material> materials = Material.values().stream()
.filter(material -> !IGNORED_EXACT.contains(material))
.filter(material -> !IGNORED_MATERIALS.contains(material))
.toList();
this.getPlayers().forEach(player -> {
ItemStack item = ItemStack.of(materials.get(ThreadLocalRandom.current().nextInt(materials.size())));
player.getInventory().addItemStack(item);
});
return TaskSchedule.seconds(this.timeToNextItem);
});
}
}
@@ -0,0 +1,32 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.pillars;
import eu.mhsl.minenet.minigames.instance.game.Game;
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.room.Room;
import eu.mhsl.minenet.minigames.message.component.TranslatedComponent;
import net.minestom.server.item.Material;
import java.util.Map;
public class PillarsFactory implements GameFactory {
@Override
public TranslatedComponent name() {
return TranslatedComponent.byId("game_Pillars#name");
}
@Override
public TranslatedComponent description() {
return TranslatedComponent.byId("game_Pillars#description");
}
@Override
public Material symbol() {
return Material.BEDROCK;
}
@Override
public Game manufacture(Room parent, Map<String, Option<?>> configuration) throws Exception {
return new Pillars().setParent(parent);
}
}
@@ -0,0 +1,86 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace;
import eu.mhsl.minenet.minigames.instance.Dimension;
import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame;
import eu.mhsl.minenet.minigames.score.FirstWinsScore;
import eu.mhsl.minenet.minigames.util.BatchUtil;
import eu.mhsl.minenet.minigames.util.CommonProperties;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.GameMode;
import net.minestom.server.entity.Player;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.instance.batch.AbsoluteBlockBatch;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture;
public class PlayerRace extends StatelessGame {
private final int height = 64;
private final Map<Player, Double> zLine = new WeakHashMap<>();
private final Map<Player, Double> zSpawnPoint = new WeakHashMap<>();
private final int obstacleCount;
public PlayerRace(int obstacleCount) {
super(Dimension.THE_END.key, "PlayerRace", new FirstWinsScore());
this.setGenerator(new PlayerRaceChunkgenerator(this.height, obstacleCount));
this.obstacleCount = obstacleCount;
}
@Override
protected boolean onPlayerJoin(Player p) {
p.setGameMode(GameMode.ADVENTURE);
this.zLine.put(p, 32.0);
this.zSpawnPoint.put(p, 14.5);
return false;
}
@Override
protected void onLoad(@NotNull CompletableFuture<Void> callback) {
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
for(int x = 0; x < 16; x++) {
batch.setBlock(x,this.height + 1, 15, Block.OAK_FENCE.withProperties(CommonProperties.fenceEastWest));
}BatchUtil.loadAndApplyBatch(batch, this, () -> callback.complete(null));
}
@Override
protected void onStart() {
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
for(int x = 0; x < 16; x++) {
batch.setBlock(x,this.height + 1, 15, Block.AIR);
}BatchUtil.loadAndApplyBatch(batch, this, () -> {});
}
@Override
public Pos getSpawn() {
return new Pos(8, this.height + 1.5, 8);
}
public Pos getNewSpawn(Player player) {
return new Pos(8, this.height + 1.5, this.zSpawnPoint.get(player));
}
@Override
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
var player = playerMoveEvent.getPlayer();
if(playerMoveEvent.getNewPosition().y() < this.height - 5) {
player.teleport(this.isBeforeBeginning ? this.getSpawn() : this.getNewSpawn(player));
return;
}
if(playerMoveEvent.getNewPosition().z() >= this.zLine.get(player) && this.isRunning) {
this.zLine.merge(player, 19.0, Double::sum);
this.zSpawnPoint.merge(player, 19.0, Double::sum);
}
if(playerMoveEvent.getNewPosition().z() >= 16 + this.obstacleCount * 19 && this.isRunning) {
this.getScore().insertResult(player);
player.setGameMode(GameMode.SPECTATOR);
}
if(playerMoveEvent.getNewPosition().z() >= 16 && this.isBeforeBeginning)
player.teleport(this.getSpawn());
}
}
@@ -0,0 +1,61 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles.Obstacle;
import eu.mhsl.minenet.minigames.util.GeneratorUtils;
import eu.mhsl.minenet.minigames.world.BlockPallet;
import eu.mhsl.minenet.minigames.world.generator.featureEnriched.ValeGenerator;
import eu.mhsl.minenet.minigames.world.generator.terrain.BaseGenerator;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
public class PlayerRaceChunkgenerator extends BaseGenerator {
private static final int START_LENGTH = 16;
private static final int GAP_LENGTH = 3;
private static final int PERIOD = Obstacle.LENGTH + GAP_LENGTH;
public PlayerRaceChunkgenerator(int height, int obstacleCount) {
ValeGenerator vale = new ValeGenerator();
vale.setXShiftMultiplier(integer -> 0.5d);
vale.setHeightNoiseMultiplier(integer -> 2);
this.addMixIn(vale);
int finishStart = START_LENGTH + obstacleCount * PERIOD;
int finishEnd = finishStart + 16;
this.addMixIn(unit -> {
Point chunkStart = unit.absoluteStart();
if(
chunkStart.chunkX() != 0 ||
chunkStart.blockZ() + 16 <= 0 ||
chunkStart.blockZ() >= finishEnd
) return;
GeneratorUtils.foreachXZ(unit, point -> {
Block ground = groundBlockAt(point.blockZ(), finishStart, finishEnd);
if(ground != null) unit.modifier().setBlock(point.withY(height), ground);
});
for(int i = 0; i < obstacleCount; i++) {
int obstacleZ = START_LENGTH + i * PERIOD;
if(obstacleZ < chunkStart.blockZ() || obstacleZ >= chunkStart.blockZ() + 16) continue;
GenerationUnit fork = unit.fork(
new Vec(chunkStart.blockX(), height - 8, obstacleZ),
new Vec(chunkStart.blockX() + 16, height + 16, obstacleZ + Obstacle.LENGTH)
);
Obstacle.getRandomObstacle().generate(fork, new Vec(chunkStart.blockX(), height, obstacleZ));
}
});
}
private static Block groundBlockAt(int z, int finishStart, int finishEnd) {
if(z < 0 || z >= finishEnd) return null;
if(z < START_LENGTH) return BlockPallet.GROUND.rnd();
if(z >= finishStart) return Block.DIAMOND_BLOCK;
int relative = (z - START_LENGTH) % PERIOD;
return relative >= Obstacle.LENGTH ? BlockPallet.GROUND.rnd() : null;
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace;
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 PlayerRaceFactory implements GameFactory {
@Override
public TranslatedComponent name() {
return TranslatedComponent.byId("game_PlayerRace#name");
}
@Override
public ConfigManager configuration() {
return new ConfigManager()
.addOption(new NumericOption("length", Material.RAIL, TranslatedComponent.byId("optionCommon#length"), 8, 12, 16, 20));
}
@Override
public Game manufacture(Room parent, Map<String, Option<?>> configuration) throws Exception {
return new PlayerRace(configuration.get("length").getAsInt()).setParent(parent);
}
@Override
public Material symbol() {
return Material.DIRT_PATH;
}
@Override
public TranslatedComponent description() {
return TranslatedComponent.byId("game_PlayerRace#description");
}
}
@@ -0,0 +1,30 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class BambooThicket extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
unit.modifier().setBlock(point.withY(start.blockY()), rnd.nextInt(3) == 0 ? Block.PODZOL : Block.GRASS_BLOCK);
int z = relZ(point, start);
if(z < 2 || z > 13 || rnd.nextInt(100) >= 45) return;
int height = 3 + rnd.nextInt(3);
for(int y = 1; y <= height; y++) {
String leaves = y == height ? "large" : y == height - 1 ? "small" : "none";
unit.modifier().setBlock(
point.withY(start.blockY() + y),
Block.BAMBOO.withProperty("age", "1").withProperty("leaves", leaves)
);
}
});
}
}
@@ -0,0 +1,44 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class BasaltPillars extends Obstacle {
private final JNoise ground = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.13)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
double noise = this.ground.evaluateNoise(point.blockX(), point.blockZ());
Block floor = noise > 0.4 ? Block.SOUL_SOIL : noise < -0.4 ? Block.BASALT : Block.BLACKSTONE;
unit.modifier().setBlock(point.withY(start.blockY()), floor);
});
for(int i = 0; i < 16; i++) {
int x = rnd.nextInt(16);
int z = 2 + rnd.nextInt(12);
int height = 2 + rnd.nextInt(3);
boolean thick = rnd.nextInt(3) == 0 && x < 15;
Block pillar = rnd.nextInt(3) == 0 ? Block.POLISHED_BASALT : Block.BASALT;
for(int y = 1; y <= height; y++) {
unit.modifier().setBlock(start.add(x, y, z), pillar);
if(thick) {
unit.modifier().setBlock(start.add(x + 1, y, z), pillar);
}
}
if(rnd.nextInt(3) == 0) {
unit.modifier().setBlock(start.add(x, height + 1, z), Block.SOUL_LANTERN);
}
}
}
}
@@ -0,0 +1,28 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class BerryPatch extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
int z = relZ(point, start);
int pathX = 7 + (int) Math.round(5 * Math.sin(z * Math.PI / 7.5));
boolean onPath = relX(point, start) == pathX || relX(point, start) == pathX + 1;
boolean overgrown = z == 8;
unit.modifier().setBlock(point.withY(start.blockY()), onPath ? Block.DIRT_PATH : Block.GRASS_BLOCK);
boolean bush = onPath ? overgrown : z >= 1 && z <= 14 && rnd.nextInt(100) < 45;
if(bush) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.SWEET_BERRY_BUSH.withProperty("age", "3"));
}
});
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class BoulderTrench extends Obstacle {
private static final int[] DEPTHS = {0, 0, 0, 0, 0, 1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0};
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
int depth = DEPTHS[relZ(point, start)];
for(int y = start.blockY() - 2; y < start.blockY() - depth; y++) {
unit.modifier().setBlock(point.withY(y), Block.STONE);
}
Block floor = depth == 0
? Block.GRASS_BLOCK
: rnd.nextBoolean() ? Block.COARSE_DIRT : Block.GRAVEL;
unit.modifier().setBlock(point.withY(start.blockY() - depth), floor);
});
for(int i = 0; i < 10; i++) {
int x = rnd.nextInt(16);
int z = 6 + rnd.nextInt(4);
unit.modifier().setBlock(start.add(x, -1, z), rnd.nextBoolean() ? Block.MOSSY_COBBLESTONE : Block.COBBLESTONE);
if(rnd.nextInt(3) == 0) {
unit.modifier().setBlock(start.add(x, 0, z), Block.MOSSY_COBBLESTONE);
}
}
for(int i = 0; i < 4; i++) {
unit.modifier().setBlock(start.add(rnd.nextInt(16), -1, 6 + rnd.nextInt(4)), Block.COBWEB);
}
}
}
@@ -0,0 +1,65 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class ChorusGrove extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
Block floor = rnd.nextInt(14) == 0 ? Block.PURPUR_BLOCK : Block.END_STONE;
unit.modifier().setBlock(point.withY(start.blockY()), floor);
});
int[] rows = {5, 10};
for(int i = 0; i < rows.length; i++) {
int gapStart = i % 2 == 0 ? 1 + rnd.nextInt(4) : 10 + rnd.nextInt(4);
boolean[] hasWall = new boolean[16];
for(int x = 0; x < 16; x++) {
hasWall[x] = x < gapStart || x >= gapStart + 2;
}
for(int x = 0; x < 16; x++) {
if(!hasWall[x]) continue;
boolean westConnected = x > 0 && hasWall[x - 1];
boolean eastConnected = x < 15 && hasWall[x + 1];
Block wall = Block.END_STONE_BRICK_WALL
.withProperty("west", westConnected ? "low" : "none")
.withProperty("east", eastConnected ? "low" : "none")
.withProperty("up", String.valueOf(!westConnected || !eastConnected || x % 4 == 0));
unit.modifier().setBlock(start.add(x, 1, rows[i]), wall);
if(rnd.nextInt(6) == 0) {
unit.modifier().setBlock(start.add(x, 2, rows[i]), Block.END_ROD);
}
}
}
for(int i = 0; i < 15; i++) {
int x = rnd.nextInt(16);
int z = 2 + rnd.nextInt(12);
if(z == 5 || z == 10) continue;
int height = 1 + rnd.nextInt(3);
for(int y = 1; y <= height; y++) {
unit.modifier().setBlock(start.add(x, y, z), Block.CHORUS_PLANT);
}
unit.modifier().setBlock(start.add(x, height + 1, z), Block.CHORUS_FLOWER.withProperty("age", "5"));
}
for(int i = 0; i < 5; i++) {
int x = rnd.nextInt(16);
int z = 2 + rnd.nextInt(12);
if(z == 5 || z == 10) continue;
unit.modifier().setBlock(start.add(x, 1, z), Block.PURPUR_BLOCK);
if(rnd.nextBoolean()) {
unit.modifier().setBlock(start.add(x, 2, z), Block.PURPUR_BLOCK);
}
}
}
}
@@ -0,0 +1,46 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class CobwebGrove extends Obstacle {
private final JNoise soil = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.12)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
Block floor = this.soil.evaluateNoise(point.blockX(), point.blockZ()) > 0 ? Block.PODZOL : Block.COARSE_DIRT;
unit.modifier().setBlock(point.withY(start.blockY()), floor);
int z = relZ(point, start);
if(z < 2 || z > 13) return;
int roll = rnd.nextInt(100);
if(roll < 28) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.COBWEB);
if(roll < 8) unit.modifier().setBlock(point.withY(start.blockY() + 2), Block.COBWEB);
} else if(roll < 36) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.FERN);
}
});
for(int i = 0; i < 4; i++) {
int x = rnd.nextInt(16);
int z = 3 + rnd.nextInt(10);
int height = 1 + rnd.nextInt(2);
for(int y = 1; y <= height; y++) {
unit.modifier().setBlock(start.add(x, y, z), Block.SPRUCE_LOG);
}
}
}
}
@@ -0,0 +1,46 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class DripstoneSpikes extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point ->
unit.modifier().setBlock(point.withY(start.blockY()), rnd.nextInt(6) == 0 ? Block.STONE : Block.DRIPSTONE_BLOCK));
int[] rows = {4, 8, 12};
for(int i = 0; i < rows.length; i++) {
int gapStart = i % 2 == 0 ? 2 + rnd.nextInt(4) : 9 + rnd.nextInt(4);
for(int x = 0; x < 16; x++) {
if(x >= gapStart && x < gapStart + 2) continue;
unit.modifier().setBlock(start.add(x, 1, rows[i]), Block.DRIPSTONE_BLOCK);
this.placeSpike(unit, start.add(0, 1, 0), x, rows[i], 1 + rnd.nextInt(2));
}
}
for(int i = 0; i < 12; i++) {
int x = rnd.nextInt(16);
int z = 2 + rnd.nextInt(12);
if(z == 4 || z == 8 || z == 12) continue;
this.placeSpike(unit, start, x, z, 1 + rnd.nextInt(3));
}
}
private void placeSpike(GenerationUnit unit, Point start, int x, int z, int height) {
for(int y = 1; y <= height; y++) {
String thickness = y == height ? "tip" : y == height - 1 ? "frustum" : "base";
unit.modifier().setBlock(
start.add(x, y, z),
Block.POINTED_DRIPSTONE
.withProperty("thickness", thickness)
.withProperty("vertical_direction", "up")
);
}
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class GlassPaneWeave extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point ->
unit.modifier().setBlock(point.withY(start.blockY()), rnd.nextInt(5) == 0 ? Block.CALCITE : Block.QUARTZ_BLOCK));
int[] rows = {4, 8, 12};
for(int i = 0; i < rows.length; i++) {
int gapStart = i % 2 == 0 ? 2 + rnd.nextInt(3) : 11 + rnd.nextInt(3);
boolean[] hasPane = new boolean[16];
for(int x = 0; x < 16; x++) {
hasPane[x] = x < gapStart || x >= gapStart + 2;
}
for(int x = 0; x < 16; x++) {
if(!hasPane[x]) continue;
Block pane = (rnd.nextInt(6) == 0 ? Block.GRAY_STAINED_GLASS_PANE : Block.WHITE_STAINED_GLASS_PANE)
.withProperty("west", String.valueOf(x > 0 && hasPane[x - 1]))
.withProperty("east", String.valueOf(x < 15 && hasPane[x + 1]));
unit.modifier().setBlock(start.add(x, 1, rows[i]), pane);
unit.modifier().setBlock(start.add(x, 2, rows[i]), pane);
}
}
}
}
@@ -0,0 +1,35 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class HayHurdles extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
unit.modifier().setBlock(point.withY(start.blockY()), Block.GRASS_BLOCK);
if(relZ(point, start) % 3 != 0 && rnd.nextInt(8) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.SHORT_GRASS);
}
});
int[] rows = {3, 6, 9, 12};
for(int i = 0; i < rows.length; i++) {
boolean tall = i % 2 == 1;
int gapStart = rnd.nextInt(14);
for(int x = 0; x < 16; x++) {
if(tall && (x == gapStart || x == gapStart + 1)) continue;
Block block = rnd.nextInt(12) == 0 ? Block.CARVED_PUMPKIN : Block.HAY_BLOCK;
unit.modifier().setBlock(start.add(x, 1, rows[i]), block);
if(tall) {
unit.modifier().setBlock(start.add(x, 2, rows[i]), Block.HAY_BLOCK);
}
}
}
}
}
@@ -0,0 +1,35 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class HoneyField extends Obstacle {
private final JNoise sticky = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.2)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
boolean stickyFloor = this.sticky.evaluateNoise(point.blockX(), point.blockZ()) > 0.35;
unit.modifier().setBlock(point.withY(start.blockY()), stickyFloor ? Block.HONEY_BLOCK : Block.HONEYCOMB_BLOCK);
});
for(int wallZ : new int[]{5, 10}) {
int gapStart = wallZ == 5 ? 1 + rnd.nextInt(5) : 8 + rnd.nextInt(5);
for(int x = 0; x < 16; x++) {
if(x >= gapStart && x < gapStart + 2) continue;
unit.modifier().setBlock(start.add(x, 1, wallZ), Block.HONEY_BLOCK);
unit.modifier().setBlock(start.add(x, 2, wallZ), Block.HONEY_BLOCK);
}
}
}
}
@@ -0,0 +1,54 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class IceRink extends Obstacle {
private final JNoise streaks = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.15)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
double noise = this.streaks.evaluateNoise(point.blockX(), point.blockZ());
Block floor = noise > 0.45 ? Block.BLUE_ICE : noise < -0.55 ? Block.ICE : Block.PACKED_ICE;
unit.modifier().setBlock(point.withY(start.blockY()), floor);
if(relZ(point, start) >= 2 && relZ(point, start) <= 13 && rnd.nextInt(14) == 0) {
unit.modifier().setBlock(
point.withY(start.blockY() + 1),
Block.SNOW.withProperty("layers", String.valueOf(1 + rnd.nextInt(2)))
);
}
});
for(int wallZ : new int[]{5, 10}) {
int gapStart = wallZ == 5 ? 1 + rnd.nextInt(4) : 9 + rnd.nextInt(4);
for(int x = 0; x < 16; x++) {
if(x >= gapStart && x < gapStart + 3) continue;
unit.modifier().setBlock(start.add(x, 1, wallZ), Block.PACKED_ICE);
unit.modifier().setBlock(start.add(x, 2, wallZ), Block.ICE);
if(rnd.nextInt(4) == 0) {
unit.modifier().setBlock(start.add(x, 3, wallZ), Block.SNOW.withProperty("layers", "1"));
}
}
}
for(int i = 0; i < 5; i++) {
int x = rnd.nextInt(16);
int z = 2 + rnd.nextInt(12);
if(z == 5 || z == 10) continue;
unit.modifier().setBlock(start.add(x, 1, z), Block.PACKED_ICE);
unit.modifier().setBlock(start.add(x, 2, z), Block.SNOW.withProperty("layers", "1"));
}
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import eu.mhsl.minenet.minigames.world.BlockPallet;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class LadderWall extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point ->
unit.modifier().setBlock(point.withY(start.blockY()), BlockPallet.STONE.rnd()));
for(int x = 0; x < 16; x++) {
boolean hasLadder = rnd.nextInt(3) != 0;
for(int y = 1; y <= 3; y++) {
for(int z = 7; z <= 8; z++) {
Block block = rnd.nextInt(3) == 0 ? Block.MOSSY_STONE_BRICKS : Block.STONE_BRICKS;
unit.modifier().setBlock(start.add(x, y, z), block);
}
if(hasLadder) {
unit.modifier().setBlock(start.add(x, y, 6), Block.LADDER.withProperty("facing", "north"));
}
unit.modifier().setBlock(start.add(x, y, 9), Block.LADDER.withProperty("facing", "south"));
}
if(x % 5 == 2) {
unit.modifier().setBlock(start.add(x, 4, 7), Block.LANTERN);
}
}
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import eu.mhsl.minenet.minigames.world.BlockPallet;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.*;
class LadderWall2 extends Obstacle{
@Override
public void generate(GenerationUnit unit, Point start) {
forEachColumn(start, point ->
unit.modifier().setBlock(point.withY(start.blockY()), BlockPallet.STREET.rnd()));
this.buildWall(unit, start,4, 4, false);
this.buildWall(unit, start,10, 8, true);
}
private void buildWall(GenerationUnit unit, Point start, int z, int height, boolean lastRow) {
Random random = new Random();
int y = 1;
for(int x = 0; x < 16; x++) {
for(int wy = 0; wy <= height; wy++) {
unit.modifier().setBlock(start.add(x, y + wy, z), BlockPallet.STONE.rnd());
}
}
for(int wy = lastRow ? 2 : 0; wy <= height; wy++) {
Set<Integer> noLadder = new HashSet<>();
for(int i = 0; i < 5; i++) {
noLadder.add(random.nextInt(16));
}
for(int x = 0; x < 16; x++) {
if(!noLadder.contains(x))
unit.modifier().setBlock(start.add(x, y + wy, z - 1), Block.LADDER);
}
if(!lastRow) {
for(int x = 7; x < 9; x++) {
unit.modifier().setBlock(start.add(x, y + wy, z + 1), Block.LADDER.withProperty("facing", "south"));
}
}
}
}
}
@@ -0,0 +1,35 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import eu.mhsl.minenet.minigames.world.BlockPallet;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class LeafHedges extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
unit.modifier().setBlock(point.withY(start.blockY()), Block.GRASS_BLOCK);
int z = relZ(point, start);
if(z % 4 != 0 && z >= 1 && z <= 14 && rnd.nextInt(10) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), BlockPallet.FLOWER.rnd());
}
});
int[] rows = {4, 8, 12};
for(int i = 0; i < rows.length; i++) {
int gapStart = i % 2 == 0 ? 11 + rnd.nextInt(3) : 1 + rnd.nextInt(3);
for(int x = 0; x < 16; x++) {
if(x >= gapStart && x < gapStart + 2) continue;
Block leaves = (rnd.nextInt(10) < 3 ? Block.FLOWERING_AZALEA_LEAVES : Block.AZALEA_LEAVES)
.withProperty("persistent", "true");
unit.modifier().setBlock(start.add(x, 1, rows[i]), leaves);
unit.modifier().setBlock(start.add(x, 2, rows[i]), leaves);
}
}
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class MudPools extends Obstacle {
private final JNoise pools = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.11)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
int z = relZ(point, start);
double noise = this.pools.evaluateNoise(point.blockX(), point.blockZ());
boolean pool = z >= 1 && z <= 14 && noise > 0.05;
if(pool) {
unit.modifier().setBlock(point.withY(start.blockY() - 1), Block.MUD);
return;
}
Block floor = noise > -0.3 ? Block.MUDDY_MANGROVE_ROOTS : Block.GRASS_BLOCK;
unit.modifier().setBlock(point.withY(start.blockY() - 1), Block.MUD);
unit.modifier().setBlock(point.withY(start.blockY()), floor);
if(z >= 2 && z <= 13 && rnd.nextInt(6) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.MANGROVE_ROOTS);
}
});
}
}
@@ -0,0 +1,25 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
class Mudpit extends Obstacle {
private final JNoise curves = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.1)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
forEachColumn(start, point -> {
double relative = relZ(point, start);
double modifier = -1.5 * Math.cos(relative * Math.PI/7.5) + 1.5;
double heightAtBlock = start.blockY() + this.curves.evaluateNoise(point.blockX(), point.blockZ()) * modifier;
unit.modifier().setBlock(point.withY(heightAtBlock), Block.SOUL_SAND);
unit.modifier().setBlock(point.withY(heightAtBlock-1), Block.SOUL_SAND);
});
}
}
@@ -0,0 +1,37 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class MushroomRoofs extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
unit.modifier().setBlock(point.withY(start.blockY()), Block.MYCELIUM);
int z = relZ(point, start);
if(z >= 1 && z <= 14 && rnd.nextInt(20) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), rnd.nextBoolean() ? Block.RED_MUSHROOM : Block.BROWN_MUSHROOM);
}
});
for(int stemZ : new int[]{4, 8, 12}) {
int x = 4 + rnd.nextInt(8);
Block cap = rnd.nextBoolean() ? Block.RED_MUSHROOM_BLOCK : Block.BROWN_MUSHROOM_BLOCK;
unit.modifier().setBlock(start.add(x, 1, stemZ), Block.MUSHROOM_STEM);
for(int capX = x - 3; capX <= x + 3; capX++) {
for(int capZ = stemZ - 1; capZ <= stemZ + 1; capZ++) {
if(capX < 0 || capX > 15 || capZ < 0 || capZ > 15) continue;
if(Math.abs(capX - x) == 3 && Math.abs(capZ - stemZ) == 1) continue;
unit.modifier().setBlock(start.add(capX, 2, capZ), cap);
}
}
}
}
}
@@ -0,0 +1,69 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import eu.mhsl.minenet.minigames.util.GeneratorUtils;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Consumer;
public abstract class Obstacle {
public static final int LENGTH = 16;
@SuppressWarnings("StaticInitializerReferencesSubClass")
private static final List<Obstacle> obstacles = List.of(
new Mudpit(),
new IceRink(),
new HoneyField(),
new CobwebGrove(),
new BerryPatch(),
new WaterChannel(),
new HayHurdles(),
new SlimePatches(),
new MudPools(),
new PowderSnowField(),
new DripstoneSpikes(),
new ChorusGrove(),
new BasaltPillars(),
new WallSlalom(),
new LeafHedges(),
new MushroomRoofs(),
new SandDunes(),
new LadderWall(),
new LadderWall2(),
new GlassPaneWeave(),
new BoulderTrench(),
new BambooThicket()
);
public abstract void generate(GenerationUnit unit, Point start);
private static final List<Obstacle> remaining = new ArrayList<>();
public static synchronized Obstacle getRandomObstacle() {
if(remaining.isEmpty()) {
remaining.addAll(obstacles);
}
int index = ThreadLocalRandom.current().nextInt(remaining.size());
return remaining.remove(index);
}
protected static void forEachColumn(Point start, Consumer<Point> callback) {
GeneratorUtils.foreachXZ(start, start.add(15, 0, LENGTH - 1), callback);
}
protected static int relX(Point point, Point start) {
return point.blockX() - start.blockX();
}
protected static int relZ(Point point, Point start) {
return point.blockZ() - start.blockZ();
}
protected static Random seededRandom(Point start) {
return new Random(start.blockX() * 341873128712L + start.blockZ() * 132897987541L);
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class PowderSnowField extends Obstacle {
private final JNoise drifts = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.14)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
int z = relZ(point, start);
int laneX = 7 + (int) Math.round(3 * Math.sin(z * Math.PI / 7.5));
boolean onLane = relX(point, start) == laneX || relX(point, start) == laneX + 1;
double noise = this.drifts.evaluateNoise(point.blockX(), point.blockZ());
boolean drift = z >= 2 && z <= 13 && noise > (onLane ? 0.45 : 0.0);
unit.modifier().setBlock(point.withY(start.blockY()), onLane ? Block.PACKED_ICE : Block.SNOW_BLOCK);
if(drift) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.POWDER_SNOW);
if(noise > 0.6) {
unit.modifier().setBlock(point.withY(start.blockY() + 2), Block.POWDER_SNOW);
}
} else if(!onLane && rnd.nextInt(6) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.SNOW.withProperty("layers", "1"));
}
});
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class SandDunes extends Obstacle {
private final JNoise dunes = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.08)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
double taper = -1.25 * Math.cos(relZ(point, start) * Math.PI / 7.5) + 1.25;
double noise = (this.dunes.evaluateNoise(point.blockX(), point.blockZ()) + 1) / 2;
int top = start.blockY() + (int) Math.round(noise * taper);
for(int y = start.blockY() - 1; y < top; y++) {
unit.modifier().setBlock(point.withY(y), Block.SANDSTONE);
}
unit.modifier().setBlock(point.withY(top), Block.SAND);
if(top > start.blockY() + 1 && rnd.nextInt(14) == 0) {
unit.modifier().setBlock(point.withY(top + 1), Block.DEAD_BUSH);
} else if(top == start.blockY() && relZ(point, start) >= 2 && relZ(point, start) <= 13 && rnd.nextInt(22) == 0) {
int cactusHeight = 1 + rnd.nextInt(2);
for(int y = 1; y <= cactusHeight; y++) {
unit.modifier().setBlock(point.withY(top + y), Block.CACTUS);
}
}
});
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator;
import de.articdive.jnoise.pipeline.JNoise;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class SlimePatches extends Obstacle {
private final JNoise blobs = JNoise.newBuilder()
.perlin(PerlinNoiseGenerator.newBuilder().build())
.scale(0.18)
.build();
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
int z = relZ(point, start);
boolean slime = z >= 1 && z <= 14 && this.blobs.evaluateNoise(point.blockX(), point.blockZ()) > -0.2;
unit.modifier().setBlock(point.withY(start.blockY()), slime ? Block.SLIME_BLOCK : Block.MOSS_BLOCK);
if(!slime && z >= 2 && z <= 13 && rnd.nextInt(18) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.AZALEA);
}
});
}
}
@@ -0,0 +1,38 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class WallSlalom extends Obstacle {
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point ->
unit.modifier().setBlock(point.withY(start.blockY()), rnd.nextInt(4) == 0 ? Block.ANDESITE : Block.STONE));
int[] rows = {4, 8, 12};
for(int i = 0; i < rows.length; i++) {
int gapStart = i % 2 == 0 ? 1 + rnd.nextInt(3) : 10 + rnd.nextInt(3);
boolean[] hasWall = new boolean[16];
for(int x = 0; x < 16; x++) {
hasWall[x] = x < gapStart || x >= gapStart + 2;
}
for(int x = 0; x < 16; x++) {
if(!hasWall[x]) continue;
boolean westConnected = x > 0 && hasWall[x - 1];
boolean eastConnected = x < 15 && hasWall[x + 1];
Block wall = Block.STONE_BRICK_WALL
.withProperty("west", westConnected ? "low" : "none")
.withProperty("east", eastConnected ? "low" : "none")
.withProperty("up", String.valueOf(!westConnected || !eastConnected || x % 4 == 0));
unit.modifier().setBlock(start.add(x, 1, rows[i]), wall);
}
}
}
}
@@ -0,0 +1,37 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.obstacles;
import net.minestom.server.coordinate.Point;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.generator.GenerationUnit;
import java.util.Random;
class WaterChannel extends Obstacle {
private static final int[] DEPTHS = {0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1, 0, 0};
@Override
public void generate(GenerationUnit unit, Point start) {
Random rnd = seededRandom(start);
forEachColumn(start, point -> {
int depth = DEPTHS[relZ(point, start)];
if(depth == 0) {
unit.modifier().setBlock(point.withY(start.blockY()), rnd.nextInt(3) == 0 ? Block.SMOOTH_SANDSTONE : Block.SAND);
return;
}
Block bottom = rnd.nextInt(5) == 0 ? Block.DARK_PRISMARINE : Block.PRISMARINE;
unit.modifier().setBlock(point.withY(start.blockY() - depth), bottom);
for(int y = start.blockY() - depth + 1; y <= start.blockY(); y++) {
unit.modifier().setBlock(point.withY(y), Block.WATER);
}
if(depth >= 2 && rnd.nextInt(4) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() - depth + 1), Block.SEAGRASS);
}
if(rnd.nextInt(20) == 0) {
unit.modifier().setBlock(point.withY(start.blockY() + 1), Block.LILY_PAD);
}
});
}
}
@@ -110,7 +110,7 @@ public class SpaceSnake extends StatelessGame {
playerMoveEvent.getPlayer().teleport(this.getSpawn());
playerMoveEvent.getPlayer().setGameMode(GameMode.SPECTATOR);
long livingPlayers = this.getPlayers().stream()
.filter(p -> this.getScore().hasResult(p))
.filter(p -> !this.getScore().hasResult(p))
.count();
if(livingPlayers == 1) this.setTimeLimit(10);
if(livingPlayers == 0) this.stop();
@@ -125,7 +125,7 @@ public class SpaceSnake extends StatelessGame {
}
PlayState state = this.playerStates.get(playerBlockPlaceEvent.getPlayer());
state.blocks.add(playerBlockPlaceEvent.getBlockPosition().asVec().asPosition());
state.blocks.add(new Pos(playerBlockPlaceEvent.getBlockPosition()));
state.cutToLength(pos -> this.setBlock(pos, Block.AIR));
MinecraftServer.getSchedulerManager().scheduleNextTick(() -> this.updateInv(playerBlockPlaceEvent.getPlayer()));

Some files were not shown because too many files have changed in this diff Show More