114 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
Pupsi a0124f1bcb Merge remote-tracking branch 'origin/develop' into develop 2025-12-28 18:53:52 +01:00
Pupsi c79fbf3136 fixed tournament random order at the end 2025-12-28 18:53:14 +01:00
MineTec 660eb645e4 Merge remote-tracking branch 'origin/develop' into develop 2025-12-27 11:37:36 +01:00
MineTec 3c50aca1e8 updated reward API endpoint in PublishRewardCommand 2025-12-27 11:37:33 +01:00
Pupsi 865cdfa605 added gamemode change to survival on leaving tetris 2025-12-25 00:52:56 +01:00
Pupsi fe88e3f921 added slowness to tetris 2025-12-24 13:20:05 +01:00
MineTec 5e94479949 updated locales file with enhanced Stickfight description and added BoatRace entry 2025-12-21 10:42:23 +01:00
MineTec b15c9c97b0 finished boat race 2025-12-18 21:36:22 +01:00
MineTec 334e130cf6 fixed boat stuttering 2025-12-18 20:02:26 +01:00
MineTec 31385fbd7f cleanup 2025-12-13 15:15:23 +01:00
MineTec b1b1e24104 added BoatRace game with factory and updated GameList 2025-12-03 23:08:55 +01:00
Pupsi 294d4bde0d added dashes to allowed characters in Languages 2025-10-16 22:09:29 +02:00
MineTec 8f6950f9af configure IntelliJ project settings and add .gitignore for IDE and Gradle 2025-10-16 01:07:28 +02:00
MineTec 794dc1dbb1 reformatted project 2025-10-16 00:58:52 +02:00
Pupsi cf0499df44 Merge pull request 'develop-fairScoreboard' (#4) from develop-fairScoreboard into develop
Reviewed-on: #4
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2025-10-15 22:25:34 +00:00
Pupsi 0b65a62016 Merge branch 'develop' into develop-fairScoreboard 2025-10-15 22:25:24 +00:00
MineTec c301e775c9 added translation to all messages, ChatFormatHandler and new chat icons 2025-10-16 00:24:48 +02:00
MineTec d083ca3e1a fix: include '!' in regex for language file processing 2025-10-15 22:57:45 +02:00
Pupsi 50200b46ae Merge pull request 'develop-turtleGame' (#6) from develop-turtleGame into develop
Reviewed-on: #6
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2025-10-15 20:21:27 +00:00
Pupsi 46a0f6e5be Merge remote-tracking branch 'origin/develop-turtleGame' into develop-turtleGame 2025-10-15 22:20:14 +02:00
Pupsi 758c51a2e1 Merge remote-tracking branch 'origin/develop' into develop-turtleGame
# Conflicts:
#	src/main/resources/lang/locales.map.csv
2025-10-15 22:19:39 +02:00
MineTec fd3f51c018 Merge pull request 'added SpaceSnake' (#5) from develop-spaceSnake into develop
Reviewed-on: #5
Reviewed-by: Lars Neuhaus <larslukasneuhaus@gmx.de>
2025-10-15 20:11:37 +00:00
MineTec be6b6da68e fix: prevent null instance in Turtle adaptView method 2025-10-15 22:01:10 +02:00
Pupsi 9f71523a07 lowered snack count 2025-10-15 21:56:48 +02:00
Pupsi 6d8c5ed917 solved pr comments 2025-10-15 21:33:17 +02:00
Pupsi 35dc924104 added null ckeck for boostTask and boostRefillTask 2025-10-15 21:30:11 +02:00
Pupsi eff5e36987 solved some pr comments 2025-10-15 21:26:02 +02:00
Pupsi 9e2125cba3 Merge remote-tracking branch 'origin/develop' into develop-turtleGame
# Conflicts:
#	src/main/resources/lang/locales.map.csv
2025-10-15 21:06:04 +02:00
MineTec 6076c0ca15 Refactored player state management in SpaceSnake and implemented endgame handling 2025-10-15 20:54:26 +02:00
MineTec 41028e3389 SpaceSnake displayBlock to Fallingblock
powerup detection via boundingbox
2025-10-15 20:36:52 +02:00
MineTec bc3f5f58a4 prevent diamond block as building block in SpaceSnake 2025-10-15 19:28:48 +02:00
MineTec 512805de05 Merge branch 'develop' into develop-spaceSnake 2025-10-15 00:52:30 +02:00
MineTec 9596800889 fixed extra space in lambda expression in SpaceSnake spawn calculation 2025-10-15 00:51:50 +02:00
MineTec a8a15a1c7c adjusted SpaceSnake spawn position and random bounds 2025-10-15 00:51:08 +02:00
MineTec db78ff33ce fixed typo in game name: renamed SNAKE3D to SPACESNAKE 2025-10-15 00:46:34 +02:00
MineTec 5bb07596a1 added SpaceSnake game and related assets 2025-10-15 00:42:11 +02:00
Pupsi c4aaa7acf9 added flowers and grass to HeightTerrainGenerator 2025-10-11 15:17:23 +02:00
Pupsi 097438886c added stickfight start and working length option 2025-10-11 12:22:50 +02:00
Pupsi ae59482d7c solved pr comment 2025-10-11 11:36:01 +02:00
Pupsi 123c01da14 added possibility for same scores Tournament 2025-10-11 01:07:40 +02:00
Pupsi e871c0bcb5 added possibility for same scores in PointsWinScore 2025-10-11 00:19:02 +02:00
Pupsi c3f5170c33 added random height variations for elytra race gates 2025-10-10 22:53:56 +02:00
Pupsi 90832aacd0 added some globalRestrictions 2025-10-10 16:56:26 +02:00
Pupsi dccb57b056 added LowestPointsWinScore and Stickfight win condition 2025-10-10 16:15:02 +02:00
Pupsi 368356c739 fixed unbreakable snow in Spleef 2025-10-10 15:20:43 +02:00
Pupsi 14a7e0c25f removed knockback for finished players in TrafficLightRace 2025-10-10 15:09:43 +02:00
Pupsi 398c3666e4 deathcube spectator mode when done 2025-10-10 12:00:12 +02:00
MineTec 96170e9486 Merge pull request 'added BlockBreakRace' (#3) from develop-blockBreakRace into develop
Reviewed-on: #3
Reviewed-by: Lars Neuhaus <larslukasneuhaus@gmx.de>
2025-10-05 08:03:04 +00:00
MineTec 9a97b746bc Merge branch 'develop' into develop-blockBreakRace 2025-10-05 08:02:56 +00:00
MineTec a2afc49d20 refactored BlockBreakRace to show players all items before the game starts 2025-10-04 21:38:50 +02:00
MineTec 20b93cc9ae added BlockBreakRace game and related assets 2025-10-04 21:34:40 +02:00
328 changed files with 10283 additions and 2256 deletions
+146 -2
View File
@@ -1,3 +1,120 @@
# Created by https://www.toptal.com/developers/gitignore/api/gradle,intellij,java
# Edit at https://www.toptal.com/developers/gitignore?templates=gradle,intellij,java
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
### Java ###
# Compiled class file
*.class
@@ -23,8 +140,35 @@
hs_err_pid*
replay_pid*
.idea
### Gradle ###
.gradle
build/*
**/build/
!src/**/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Avoid ignore Gradle wrappper properties
!gradle-wrapper.properties
# Cache of project
.gradletasknamecache
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
### Gradle Patch ###
# Java heap dump
*.hprof
# End of https://www.toptal.com/developers/gitignore/api/gradle,intellij,java
/resources/
/build/
/bin/
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+16
View File
@@ -0,0 +1,16 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<codeStyleSettings language="JAVA">
<option name="SPACE_BEFORE_IF_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_WHILE_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_FOR_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_TRY_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_CATCH_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_SWITCH_PARENTHESES" value="false" />
<option name="SPACE_BEFORE_SYNCHRONIZED_PARENTHESES" value="false" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<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>
+7
View File
@@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="UnqualifiedFieldAccess" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnqualifiedMethodAccess" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven3" />
<option name="name" value="maven3" />
<option name="url" value="https://oss.sonatype.org/content/repositories/snapshots" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://repo.unnamed.team/repository/unnamed-public/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven4" />
<option name="name" value="maven4" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
</component>
</project>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="openjdk-25" project-jdk-type="JavaSDK" />
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/Minigames.main.iml" filepath="$PROJECT_DIR$/.idea/modules/Minigames.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/Minigames.test.iml" filepath="$PROJECT_DIR$/.idea/modules/Minigames.test.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/eu.mhsl.minenet.Minigames.main.iml" filepath="$PROJECT_DIR$/.idea/modules/eu.mhsl.minenet.Minigames.main.iml" />
</modules>
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
<projectReimportVersion>1</projectReimportVersion>
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
<projectReimportVersion>1</projectReimportVersion>
</configuration>
</facet>
</component>
</module>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
<projectReimportVersion>1</projectReimportVersion>
</configuration>
</facet>
</component>
</module>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
<projectReimportVersion>1</projectReimportVersion>
</configuration>
</facet>
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</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;
@@ -24,7 +24,8 @@ public class Main {
private final static Logger logger = Logger.getGlobal();
public static ConfigurationNode globalConfig;
public static void main(String[] args) throws ConfigurateException {
@SuppressWarnings("ResultOfMethodCallIgnored")
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
@@ -37,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();
@@ -56,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();
@@ -21,6 +21,7 @@ public enum Resource {
private final Path path;
private final String name;
Resource(String name, boolean keepOutdated) {
this.name = name;
this.path = Path.of("resources/" + name);
@@ -38,10 +39,10 @@ public enum Resource {
}
public Path getPath() {
return path;
return this.path;
}
public String getName() {
return name;
return this.name;
}
}
@@ -12,11 +12,11 @@ public abstract class Controller<Q, R> implements Route {
@SuppressWarnings("unchecked")
public Controller() {
this.requestType = ((Class<Q>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0]);
this.requestType = ((Class<Q>) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0]);
}
@Override
public Object handle(Request request, Response response) throws Exception {
public Object handle(Request request, Response response) {
response.header("Access-Control-Allow-Origin", "*");
response.header("Access-Control-Allow-Methods", "*");
@@ -28,7 +28,7 @@ public abstract class Controller<Q, R> implements Route {
req = new Gson().fromJson(request.body(), this.requestType);
}
return new Gson().toJson(handle(req, response));
return new Gson().toJson(this.handle(req, response));
}
public abstract R handle(Q request, Response response);
@@ -10,6 +10,7 @@ import static spark.Spark.*;
public class HttpServer {
private static final ConfigurationNode apiConfig = Main.globalConfig.node("api");
public HttpServer() {
if(!apiConfig.node("enabled").getBoolean()) return;
@@ -6,8 +6,12 @@ import spark.Response;
import java.util.UUID;
record Req(UUID room) {}
record Resp() {}
record Req(UUID room) {
}
record Resp() {
}
public class CloseRoom extends Controller<Req, Resp> {
@Override
public Resp handle(Req request, Response response) {
@@ -6,8 +6,12 @@ import spark.Response;
import java.util.UUID;
record Req() {}
record Resp(UUID uuid) {}
record Req() {
}
record Resp(UUID uuid) {
}
public class CreateRoom extends Controller<Req, Resp> {
@Override
public Resp handle(Req request, Response response) {
@@ -8,8 +8,12 @@ import spark.Response;
import java.util.UUID;
record Req(UUID player, UUID room) {}
record Resp(String error) {}
record Req(UUID player, UUID room) {
}
record Resp(String error) {
}
public class QueueRoom extends Controller<Req, Resp> {
@Override
public Resp handle(Req request, Response response) {
@@ -1,9 +1,8 @@
package eu.mhsl.minenet.minigames.command;
import eu.mhsl.minenet.minigames.command.privileged.SkinCommand;
import eu.mhsl.minenet.minigames.command.privileged.*;
import eu.mhsl.minenet.minigames.command.anonymous.HubCommand;
import eu.mhsl.minenet.minigames.command.anonymous.LeaveCommand;
import eu.mhsl.minenet.minigames.command.privileged.*;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import net.minestom.server.MinecraftServer;
@@ -21,7 +20,6 @@ public enum Commands {
ROOM(new RoomCommand()),
UPDATE(new RefreshCommandsCommand()),
OP(new OpCommand()),
FAKEPLAYER(new FakeplayerCommand()),
KICK(new KickCommand()),
SKIN(new SkinCommand()),
SETOWNER(new SetRoomOwnerCommand()),
@@ -34,14 +32,18 @@ public enum Commands {
PLAYERLIMIT(new PlayerLimitCommand()),
SETMEMORIAL(new SetMemorialCommand());
Commands(Command handler) {
MinecraftServer.getCommandManager().register(handler);
}
static {
MinecraftServer.getCommandManager().setUnknownCommandCallback((sender, command) -> {
if(command.isBlank()) return;
new ChatMessage(Icon.ERROR).appendStatic("Unknown command: ").quote(command).send(sender);
new ChatMessage(Icon.ERROR)
.appendTranslated("common#unknownCommand")
.appendSpace()
.quote(command)
.send(sender);
});
}
Commands(Command handler) {
MinecraftServer.getCommandManager().register(handler);
}
}
@@ -11,20 +11,21 @@ import java.util.List;
public class PrivilegedCommand extends Command {
private final List<CommandCondition> conditions = new ArrayList<>();
public PrivilegedCommand(@NotNull String name, @Nullable String... aliases) {
super(name, aliases);
construct();
this.construct();
}
public PrivilegedCommand(@NotNull String name) {
super(name);
construct();
this.construct();
}
private void construct() {
addCondition(isPrivileged());
this.addCondition(this.isPrivileged());
setCondition((sender, commandString) -> conditions.parallelStream().allMatch(condition -> condition.canUse(sender, commandString)));
this.setCondition((sender, commandString) -> this.conditions.parallelStream().allMatch(condition -> condition.canUse(sender, commandString)));
}
protected CommandCondition isPrivileged() {
@@ -32,6 +33,6 @@ public class PrivilegedCommand extends Command {
}
protected void addCondition(CommandCondition condition) {
conditions.add(condition);
this.conditions.add(condition);
}
}
@@ -1,8 +1,8 @@
package eu.mhsl.minenet.minigames.command.anonymous;
import eu.mhsl.minenet.minigames.util.MoveInstance;
import eu.mhsl.minenet.minigames.instance.hub.Hub;
import eu.mhsl.minenet.minigames.instance.room.Room;
import eu.mhsl.minenet.minigames.util.MoveInstance;
import net.minestom.server.command.builder.Command;
import net.minestom.server.entity.Player;
@@ -10,12 +10,12 @@ public class HubCommand extends Command {
public HubCommand() {
super("hub");
setCondition(
this.setCondition(
(sender, commandString) ->
((Player) sender).getInstance() instanceof Room room && !room.apiDriven
);
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
if(Room.getRoom((Player) sender).orElseThrow().apiDriven) return;
Room.unsetRoom((Player) sender);
MoveInstance.move((Player) sender, Hub.INSTANCE);
@@ -9,8 +9,8 @@ public class LeaveCommand extends Command {
public LeaveCommand() {
super("leave");
setCondition((sender, commandString) -> ((Player) sender).getInstance() instanceof Game);
this.setCondition((sender, commandString) -> ((Player) sender).getInstance() instanceof Game);
setDefaultExecutor((sender, context) -> Room.setOwnRoom((Player) sender));
this.setDefaultExecutor((sender, context) -> Room.setOwnRoom((Player) sender));
}
}
@@ -14,15 +14,15 @@ public class DebugCommand extends PrivilegedCommand {
public DebugCommand() {
super("debug");
setDefaultExecutor((sender, args) -> {
this.setDefaultExecutor((sender, args) -> {
new ChatMessage(Icon.CHAT).appendTranslated("sample").send(sender);
new ActionBarMessage().appendTranslated("sample").send(sender);
new TitleMessage().subtitle(subtitleMessage -> subtitleMessage.appendTranslated("sample")).appendTranslated("sample").send(sender);
List<String> testplayers = new ArrayList<>() {
{
add("MineTec");
add("Goldi187");
add("Test");
this.add("MineTec");
this.add("Goldi187");
this.add("Test");
}
};
new ChatMessage(Icon.STAR, true)
@@ -1,31 +0,0 @@
package eu.mhsl.minenet.minigames.command.privileged;
import eu.mhsl.minenet.minigames.command.PrivilegedCommand;
import eu.mhsl.minenet.minigames.instance.room.Room;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.entity.Player;
import java.util.UUID;
public class FakeplayerCommand extends PrivilegedCommand {
public FakeplayerCommand() {
super("fakeplayer");
addSyntax((sender, context) -> {
if(sender instanceof Player p) {
if(p.getInstance() instanceof Room room) {
// FakePlayer.initPlayer( // TODO FakePlayer does no longer exists
// UUID.randomUUID(),
// context.getRaw("name"),
// new FakePlayerOption().setInTabList(true).setRegistered(true),
// fakePlayer -> Room.setRoom(fakePlayer, room)
// );
} else {
new ChatMessage(Icon.ERROR).appendStatic("Du musst dich in einer Raumlobby befinden!").send(sender);
}
}
}, ArgumentType.String("name"));
}
}
@@ -8,7 +8,7 @@ public class FlyCommand extends PrivilegedCommand {
public FlyCommand() {
super("fly");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
Player p = (Player) sender;
p.setVelocity(new Vec(0, 5, 0));
p.setFlying(!p.isFlying());
@@ -7,7 +7,7 @@ import net.minestom.server.entity.Player;
public class GameStartCommand extends PrivilegedCommand {
public GameStartCommand() {
super("gameStart");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
Player player = (Player) sender;
if(player.getInstance() instanceof StatelessGame game) {
game.startAccessor();
@@ -7,7 +7,7 @@ import net.minestom.server.entity.Player;
public class GameStopCommand extends PrivilegedCommand {
public GameStopCommand() {
super("gameStop");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
Player player = (Player) sender;
if(player.getInstance() instanceof StatelessGame game) {
game.stop();
@@ -12,7 +12,7 @@ public class GameTimeoutCommand extends PrivilegedCommand {
ArgumentInteger timeout = ArgumentType.Integer("timeout");
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
Player player = (Player) sender;
if(player.getInstance() instanceof StatelessGame game) {
game.setTimeLimit(context.get(timeout));
@@ -11,7 +11,7 @@ public class GamemodeCommand extends PrivilegedCommand {
super("gamemode", "gm");
addSyntax((sender, context) -> ((Player) sender).setGameMode(
this.addSyntax((sender, context) -> ((Player) sender).setGameMode(
context.get("target")),
ArgumentType.Enum("target", GameMode.class).setFormat(ArgumentEnum.Format.LOWER_CASED)
);
@@ -1,16 +1,17 @@
package eu.mhsl.minenet.minigames.command.privileged;
import eu.mhsl.minenet.minigames.command.PrivilegedCommand;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import eu.mhsl.minenet.minigames.util.Monitoring;
public class GcCommand extends PrivilegedCommand {
private static long lastRun = System.currentTimeMillis();
public GcCommand() {
super("gc");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
long nextRun = (lastRun - (System.currentTimeMillis() - 30 * 1000)) / 1000;
if(nextRun > 0) {
new ChatMessage(Icon.ERROR).appendStatic("Please wait ").appendStatic(String.valueOf(nextRun)).appendStatic(" seconds before running GC again!").send(sender);
@@ -13,7 +13,7 @@ public class InstanceProxyMoveCommand extends PrivilegedCommand {
ArgumentWord serverArgument = new ArgumentWord("server");
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
Instance room = ((Player) sender).getInstance();
room.getPlayers().forEach(player -> {
Room.unsetRoom(player);
@@ -11,15 +11,15 @@ public class KickCommand extends PrivilegedCommand {
public KickCommand() {
super("kick");
addSyntax(
this.addSyntax(
(sender, context) ->
kick(context.getRaw("player"), ""),
this.kick(context.getRaw("player"), ""),
ArgumentType.Entity("player").onlyPlayers(true)
);
addSyntax(
this.addSyntax(
(sender, context) ->
kick(context.getRaw("player"), context.getRaw("reason")),
this.kick(context.getRaw("player"), context.getRaw("reason")),
ArgumentType.Entity("player").onlyPlayers(true),
ArgumentType.String("reason")
);
@@ -1,8 +1,8 @@
package eu.mhsl.minenet.minigames.command.privileged;
import eu.mhsl.minenet.minigames.command.PrivilegedCommand;
import eu.mhsl.minenet.minigames.lang.Languages;
import eu.mhsl.minenet.minigames.lang.Lang;
import eu.mhsl.minenet.minigames.lang.Languages;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.TranslatableMessage;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
@@ -13,11 +13,11 @@ public class LangTestCommand extends PrivilegedCommand {
public LangTestCommand() {
super("langtest");
setDefaultExecutor((sender, context) -> sendMessage(Languages.getInstance().getLanguage((Player) sender), "sample").send(sender));
this.setDefaultExecutor((sender, context) -> this.sendMessage(Languages.getInstance().getLanguage((Player) sender), "sample").send(sender));
var targetString = ArgumentType.String("mapId");
addSyntax((sender, context) -> sendMessage(Languages.getInstance().getLanguage((Player) sender), context.get("mapId")).send(sender), targetString);
this.addSyntax((sender, context) -> this.sendMessage(Languages.getInstance().getLanguage((Player) sender), context.get("mapId")).send(sender), targetString);
}
private TranslatableMessage sendMessage(Lang lang, String mapId) {
@@ -11,7 +11,7 @@ public class OpCommand extends PrivilegedCommand {
public OpCommand() {
super("op");
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
Player target = MinecraftServer.getConnectionManager().getOnlinePlayerByUsername(context.getRaw("target"));
if(target != null) {
target.setPermissionLevel(4);
@@ -10,8 +10,9 @@ public class PlayerLimitCommand extends PrivilegedCommand {
ArgumentInteger count = ArgumentType.Integer("count");
addSyntax((sender, context) -> {
System.setProperty("minenet.playerlimit", String.valueOf(context.get(count)));
}, count);
this.addSyntax((sender, context) -> System.setProperty(
"minenet.playerlimit",
String.valueOf(context.get(count))
), count);
}
}
@@ -13,10 +13,11 @@ import java.net.http.HttpResponse;
public class PublishRewardCommand extends PrivilegedCommand {
private final HttpClient rewardPublishClient = HttpClient.newHttpClient();
public PublishRewardCommand() {
super("publishReward");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
try {
Room room = Room.getRoom((Player) sender).orElseThrow();
TournamentDisplay world = new TournamentDisplay(room.getTournament());
@@ -24,12 +25,12 @@ public class PublishRewardCommand extends PrivilegedCommand {
String rewardRequestJson = new Gson().toJson(room.getTournament().getRewards());
HttpRequest giveRewardsRequest = HttpRequest.newBuilder()
.uri(new URI("http://10.20.7.1:8080/api/event/reward"))
.uri(new URI("http://10.20.9.3:8080/api/event/reward"))
.POST(HttpRequest.BodyPublishers.ofString(rewardRequestJson))
.build();
room.getTournament().getRewards();
HttpResponse<Void> rawResponse = rewardPublishClient.send(giveRewardsRequest, HttpResponse.BodyHandlers.discarding());
HttpResponse<Void> rawResponse = this.rewardPublishClient.send(giveRewardsRequest, HttpResponse.BodyHandlers.discarding());
sender.sendMessage(String.format("Rewards published: HTTP %s", rawResponse.statusCode()));
} catch(Exception e) {
sender.sendMessage(e.getMessage());
@@ -1,8 +1,8 @@
package eu.mhsl.minenet.minigames.command.privileged;
import eu.mhsl.minenet.minigames.command.PrivilegedCommand;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
@@ -10,7 +10,7 @@ public class RefreshCommandsCommand extends PrivilegedCommand {
public RefreshCommandsCommand() {
super("refreshCommands");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
MinecraftServer.getConnectionManager().getOnlinePlayers().forEach(Player::refreshCommands);
new ChatMessage(Icon.SUCCESS).appendStatic("Updated command syntax!").send(sender);
});
@@ -1,10 +1,10 @@
package eu.mhsl.minenet.minigames.command.privileged;
import eu.mhsl.minenet.minigames.command.PrivilegedCommand;
import eu.mhsl.minenet.minigames.instance.room.Room;
import eu.mhsl.minenet.minigames.message.Icon;
import eu.mhsl.minenet.minigames.message.TranslatableMessage;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import eu.mhsl.minenet.minigames.instance.room.Room;
import net.minestom.server.entity.Player;
import java.util.stream.Collectors;
@@ -13,7 +13,7 @@ public class RoomCommand extends PrivilegedCommand {
public RoomCommand() {
super("room");
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
TranslatableMessage out = new ChatMessage(Icon.SCIENCE).appendStatic("Rooms:").newLine();
Room.getAllRooms().forEach((roomInstance) -> out
@@ -20,22 +20,22 @@ public class SetMemorialCommand extends PrivilegedCommand {
ArgumentString titleArgument = ArgumentType.String("title");
ArgumentString loreArgument = ArgumentType.String("lore");
materialArgument.setSuggestionCallback((sender, context, suggestion) -> {
Material
materialArgument.setSuggestionCallback(
(sender, context, suggestion) -> Material
.values()
.stream()
.map(material -> new SuggestionEntry(material.name(), Component.text(material.name())))
.forEach(suggestion::addEntry);
});
.forEach(suggestion::addEntry)
);
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
Room
.getRoom((Player) sender)
.orElseThrow()
.getTournament()
.setMemorialConfiguration(
new MemorialConfiguration(
Material.fromNamespaceId(context.get(materialArgument)),
Material.fromKey(context.get(materialArgument)),
context.get(titleArgument),
context.get(loreArgument)
)
@@ -22,22 +22,22 @@ public class SetRewardCommand extends PrivilegedCommand {
ArgumentWord materialArgument = ArgumentType.Word("material");
ArgumentStringArray amountsArgument = ArgumentType.StringArray("amount");
materialArgument.setSuggestionCallback((sender, context, suggestion) -> {
Material
materialArgument.setSuggestionCallback(
(sender, context, suggestion) -> Material
.values()
.stream()
.map(material -> new SuggestionEntry(material.name(), Component.text(material.name())))
.forEach(suggestion::addEntry);
});
.forEach(suggestion::addEntry)
);
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
Room
.getRoom((Player) sender)
.orElseThrow()
.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())
)
);
@@ -15,21 +15,21 @@ public class SetRoomOwnerCommand extends PrivilegedCommand {
public SetRoomOwnerCommand() {
super("setRoomOwner");
addCondition((sender, commandString) -> ((Player) sender).getInstance() instanceof Room);
this.addCondition((sender, commandString) -> ((Player) sender).getInstance() instanceof Room);
setDefaultExecutor((sender, context) -> {
this.setDefaultExecutor((sender, context) -> {
if(sender instanceof Player p) {
Room.getRoom(p).orElseThrow().setOwner(p);
new ChatMessage(Icon.SUCCESS).appendStatic("You are now the owner of this room!").send(sender);
new ChatMessage(Icon.SUCCESS).appendTranslated("room#ownerSelf").send(sender);
}
});
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
System.out.println("Test");
if(sender instanceof Player p) {
Player newOwner = MinecraftServer.getConnectionManager().getOnlinePlayerByUsername(context.getRaw("player"));
Room.getRoom(p).orElseThrow().setOwner(Objects.requireNonNull(newOwner));
new ChatMessage(Icon.SUCCESS).appendStatic("The new owner has been set!").send(sender);
new ChatMessage(Icon.SUCCESS).appendTranslated("room#ownerSet").send(sender);
}
}, ArgumentType.Entity("player").onlyPlayers(true));
}
@@ -9,7 +9,7 @@ public class SkinCommand extends PrivilegedCommand {
public SkinCommand() {
super("skin");
addSyntax((sender, context) -> {
this.addSyntax((sender, context) -> {
if(sender instanceof Player p) {
p.setSkin(PlayerSkin.fromUsername(context.getRaw("target")));
}
@@ -1,13 +1,19 @@
package eu.mhsl.minenet.minigames.handler;
import eu.mhsl.minenet.minigames.handler.global.*;
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;
import net.minestom.server.event.EventListener;
public enum Listeners {
SPAWN(new AddEntityToInstanceEventListener()),
LOGIN(new PlayerLoginHandler()),
LEAVE(new PlayerLeaveHandler());
LEAVE(new PlayerLeaveHandler()),
FREEZE(new PlayerFreezeHandler()),
CHAT(new ChatFormatHandler());
Listeners(EventListener<?> event) {
MinecraftServer.getGlobalEventHandler().addListener(event);
@@ -0,0 +1,29 @@
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;
public class ChatFormatHandler implements EventListener<PlayerChatEvent> {
@Override
public @NotNull Class<PlayerChatEvent> eventType() {
return PlayerChatEvent.class;
}
@Override
public @NotNull Result run(@NotNull PlayerChatEvent event) {
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,5 +1,9 @@
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.format.NamedTextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import net.minestom.server.event.EventListener;
import net.minestom.server.event.player.PlayerDisconnectEvent;
@@ -14,7 +18,11 @@ public class PlayerLeaveHandler implements EventListener<PlayerDisconnectEvent>
@Override
public @NotNull Result run(@NotNull PlayerDisconnectEvent event) {
Player p = event.getPlayer();
// new ChatMessage(Icon.SCIENCE).appendStatic("unübersetzter Leavetext: ").appendStatic(p.getDisplayName()).send(MinecraftServer.getConnectionManager().getOnlinePlayers());
new ChatMessage(Icon.LEAVE)
.appendStatic(p.getName().color(NamedTextColor.GRAY))
.appendSpace()
.appendTranslated("common#leave", NamedTextColor.DARK_GRAY)
.send(MinecraftServer.getConnectionManager().getOnlinePlayers());
return Result.SUCCESS;
}
}
@@ -2,13 +2,13 @@ package eu.mhsl.minenet.minigames.handler.global;
import eu.mhsl.minenet.minigames.Main;
import eu.mhsl.minenet.minigames.api.QueuedPlayerRooms;
import eu.mhsl.minenet.minigames.instance.hub.Hub;
import eu.mhsl.minenet.minigames.instance.room.Room;
import eu.mhsl.minenet.minigames.instance.transfer.Transfer;
import eu.mhsl.minenet.minigames.skin.SkinCache;
import eu.mhsl.minenet.minigames.util.MoveInstance;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import eu.mhsl.minenet.minigames.instance.hub.Hub;
import net.minestom.server.event.EventListener;
import net.minestom.server.event.player.AsyncPlayerConfigurationEvent;
import net.minestom.server.network.packet.server.play.TeamsPacket;
@@ -61,12 +61,11 @@ public class PlayerLoginHandler implements EventListener<AsyncPlayerConfiguratio
if(Objects.requireNonNull(Main.globalConfig.node("admins").getList(String.class)).stream().anyMatch(s -> s.equalsIgnoreCase(p.getUsername()))) {
p.setPermissionLevel(4);
}
} catch (SerializationException | NullPointerException ignored) {}
} catch(SerializationException | NullPointerException ignored) {
}
Logger.getLogger("user").info(p.getUsername() + " joined");
// new ChatMessage(Icon.SCIENCE).appendStatic("unübersetzter Jointext: ").appendStatic(p.getUsername()).send(MinecraftServer.getConnectionManager().getOnlinePlayers());
return Result.SUCCESS;
}
}
@@ -1,48 +1,85 @@
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;
Dimension(NamespaceID namespaceID, DimensionType dimType) {
public final Key namespaceID;
public final RegistryKey<DimensionType> key;
Dimension(Key namespaceID, DimensionType dimType) {
this.DIMENSION = dimType;
this.namespaceID = namespaceID;
this.key = MinecraftServer.getDimensionTypeRegistry().register(namespaceID, DIMENSION);
this.key = MinecraftServer.getDimensionTypeRegistry().register(namespaceID, this.DIMENSION);
}
}
@@ -1,25 +1,32 @@
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.instance.InstanceManager;
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);
eventNode()
this.eventNode()
.addListener(AddEntityToInstanceEvent.class, this::onEntityAdd)
.addListener(RemoveEntityFromInstanceEvent.class, this::onEntityRemove);
}
@@ -38,6 +45,7 @@ public class MineNetInstance extends InstanceContainer {
/**
* Called when Player joins this instance
*
* @param p player who is joining
* @return setCanceled
*/
@@ -47,18 +55,15 @@ public class MineNetInstance extends InstanceContainer {
/**
* Called when Player leaves this instance
*
* @param p player who is leaving
*/
protected void onPlayerLeave(Player p) {
}
/**
*
* @param target
*/
public void destroy(Instance target) {
getPlayers().forEach(player -> {
this.getPlayers().forEach(player -> {
if(target != null)
player.setInstance(target);
else
@@ -1,14 +1,14 @@
package eu.mhsl.minenet.minigames.instance.game;
import eu.mhsl.minenet.minigames.instance.MineNetInstance;
import eu.mhsl.minenet.minigames.instance.Spawnable;
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.Icon;
import eu.mhsl.minenet.minigames.message.type.ChatMessage;
import eu.mhsl.minenet.minigames.score.Score;
import eu.mhsl.minenet.minigames.util.CommonEventHandles;
import eu.mhsl.minenet.minigames.instance.Spawnable;
import eu.mhsl.minenet.minigames.instance.room.Room;
import net.minestom.server.MinecraftServer;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
@@ -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;
@@ -28,51 +28,38 @@ import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
public abstract class Game extends MineNetInstance implements Spawnable {
protected final Random rnd = new Random();
protected final Logger logger;
protected Room parentRoom;
protected boolean isRunning = false;
protected boolean isBeforeBeginning = true;
protected final Random rnd = new Random(); //TODO better way than ths?
protected final Logger logger;
public Game(DynamicRegistry.Key<DimensionType> dimensionType) {
public Game(RegistryKey<DimensionType> dimensionType) {
super(dimensionType);
MinecraftServer.getInstanceManager().registerInstance(this);
logger = Logger.getLogger("Game:" + getUuid());
this.logger = Logger.getLogger("Game:" + this.getUuid());
eventNode()
this.eventNode()
.addListener(PlayerMoveEvent.class, this::onPlayerMove)
.addListener(PlayerBlockBreakEvent.class, this::onBlockBreak)
.addListener(PlayerBlockPlaceEvent.class, this::onBlockPlace)
.addListener(ItemDropEvent.class, this::onItemDrop);
}
public Game setParent(Room parentRoom) {
this.parentRoom = parentRoom;
return this;
}
public static void initialize(GameFactory factory, List<Option<?>> options, Player owner) {
try {
Game game = factory.manufacture(Room.getRoom(owner).orElseThrow(), options);
Room originRoom = Room.getRoom(owner).orElseThrow();
Game game = factory.manufacture(originRoom, options);
game.load();
Room.getRoom(owner).orElseThrow().moveMembersToInstance(game);
originRoom.moveMembersToInstance(game);
MinecraftServer.getSchedulerManager().scheduleTask(() -> {
game.getPlayers().forEach(player -> new ChatMessage(Icon.SCIENCE)
.appendStatic(factory.name().getAssembled(player).asComponent())
new ChatMessage(Icon.INFO)
.appendTranslated(factory.name())
.newLine()
.appendStatic(factory.description().getAssembled(player).asComponent())
.send(player));
return TaskSchedule.stop();
}, TaskSchedule.seconds(3));
.appendTranslated(factory.description())
.send(originRoom.getAllMembers());
} catch(Exception e) {
new ChatMessage(Icon.ERROR).appendStatic("Instance crashed: " + e.getMessage()).send(owner);
@@ -81,11 +68,16 @@ public abstract class Game extends MineNetInstance implements Spawnable {
}
}
public Game setParent(Room parentRoom) {
this.parentRoom = parentRoom;
return this;
}
/**
* Load and start countdown
*/
public void load() {
scheduler().submitTask(() -> {
this.scheduler().submitTask(() -> {
CompletableFuture<Void> callback = new CompletableFuture<>();
this.onLoad(callback);
// callback.whenComplete((unused, throwable) -> this.start());
@@ -95,13 +87,13 @@ public abstract class Game extends MineNetInstance implements Spawnable {
}
protected void start() {
isRunning = true;
isBeforeBeginning = false;
this.isRunning = true;
this.isBeforeBeginning = false;
this.onStart();
}
public void stop() {
isRunning = false;
this.isRunning = false;
this.onStop();
this.unload();
}
@@ -109,12 +101,12 @@ public abstract class Game extends MineNetInstance implements Spawnable {
public void unload() {
this.onUnload();
getPlayers().forEach(Room::setOwnRoom);
this.getPlayers().forEach(Room::setOwnRoom);
scheduler().scheduleTask(() -> {
this.scheduler().scheduleTask(() -> {
logger.info("stopping game instance " + this.uuid);
getPlayers().forEach(player -> player.kick("timeout"));
this.logger.info("stopping game instance " + this.uuid);
this.getPlayers().forEach(player -> player.kick("timeout"));
MinecraftServer.getInstanceManager().unregisterInstance(this);
@@ -125,9 +117,14 @@ public abstract class Game extends MineNetInstance implements Spawnable {
callback.complete(null);
}
protected void onStart() {}
protected void onStop() {}
protected void onUnload() {}
protected void onStart() {
}
protected void onStop() {
}
protected void onUnload() {
}
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
@@ -151,7 +148,7 @@ public abstract class Game extends MineNetInstance implements Spawnable {
}
protected void checkAbandoned() {
scheduleNextTick((instance) -> {
this.scheduleNextTick((instance) -> {
if(instance.getPlayers().isEmpty()) this.unload();
});
}
@@ -161,7 +158,7 @@ public abstract class Game extends MineNetInstance implements Spawnable {
}
public boolean isRunning() {
return isRunning;
return this.isRunning;
}
public Pos getSpawn() {
@@ -1,21 +1,28 @@
package eu.mhsl.minenet.minigames.instance.game;
import eu.mhsl.minenet.minigames.instance.game.stateless.config.GameFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.acidRain.AcidRainFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.anvilRun.AnvilRunFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.bowSpleef.BowSpleefFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.elytraRace.ElytraRaceFactory;
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.acidRain.AcidRainFactory;
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;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.tetris.TetrisFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.sumo.SumoFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.tetris.TetrisFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.tntrun.TntRunFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.towerdefense.TowerdefenseFactory;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.trafficlightrace.TrafficLightRaceFactory;
@@ -28,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),
@@ -40,14 +47,23 @@ public enum GameList {
JUMPDIVE(new JumpDiveFactory(), GameType.JUMPNRUN),
SUMO(new SumoFactory(), GameType.PVP),
HIGHGROUND(new HighGroundFactory(), GameType.PVP),
FASTBRIDGE(new FastbridgeFactory(), GameType.OTHER);
FASTBRIDGE(new FastbridgeFactory(), GameType.OTHER),
BLOCKBREAKRACE(new BlockBreakRaceFactory(), GameType.OTHER),
SPACESNAKE(new SpaceSnakeFactory(), GameType.PVP),
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;
GameList(GameFactory factory, GameType type) {
this.factory = factory;
this.type = type;
}
public GameFactory getFactory() {
return this.factory;
}
@@ -22,15 +22,15 @@ public enum GameType {
}
public Material getIcon() {
return icon;
return this.icon;
}
public TranslatedComponent getTitle() {
return title;
return this.title;
}
public TranslatedComponent getDescription() {
return description;
return this.description;
}
@@ -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;
@@ -37,7 +37,7 @@ public class StatelessGame extends Game {
}
public String getName() {
return name;
return this.name;
}
public void setTimeLimit(int limit) {
@@ -47,20 +47,23 @@ public class StatelessGame extends Game {
this.timePlayed = 0;
}
if(this.timeLimit > 0) {
this.timeLimitTask = scheduler().submitTask(() -> {
if(!isRunning || timeLimit == 0) return TaskSchedule.stop();
if(timeLimit <= timePlayed) {
stop();
this.timeLimitTask = this.scheduler().submitTask(() -> {
if(!this.isRunning || this.timeLimit == 0) return TaskSchedule.stop();
if(this.timeLimit <= this.timePlayed) {
this.stop();
return TaskSchedule.stop();
}
int timeLeft = timeLimit - timePlayed;
int timeLeft = this.timeLimit - this.timePlayed;
switch(timeLeft) {
case 90, 60, 30, 10, 5, 4, 3, 2, 1 ->
new ChatMessage(Icon.SCIENCE).appendStatic("Noch " + timeLeft + " Sekunden!").send(getPlayers());
case 90, 60, 30, 10, 5, 4, 3, 2, 1 -> new ChatMessage(Icon.TIME)
.appendStatic(String.valueOf(timeLeft))
.appendSpace()
.appendTranslated(timeLeft == 1 ? "common#secondsLeft_singular" : "common#secondsLeft_plural")
.send(this.getPlayers());
}
timePlayed++;
this.timePlayed++;
return TaskSchedule.seconds(1);
}, ExecutionType.TICK_START);
@@ -77,37 +80,48 @@ public class StatelessGame extends Game {
* When overriding make sure to call this::start after countdown!
*/
protected CompletableFuture<Void> countdownStart() {
return new Countdown(TitleMessage.class)
.countdown(Audience.audience(getPlayers()), 5, countdownModifier -> countdownModifier.message = new TitleMessage(Duration.ofMillis(300), Duration.ofMillis(700))
.subtitle(subtitleMessage -> subtitleMessage.appendStatic(Component.text("in ", NamedTextColor.DARK_GREEN))
.appendStatic(Component.text(countdownModifier.timeLeft, NamedTextColor.GREEN))
.appendStatic(Component.text(" seconds", NamedTextColor.DARK_GREEN))));
}
Duration fadeIn = Duration.ofMillis(300);
Duration stay = Duration.ofMillis(700);
return new Countdown()
.countdown(
Audience.audience(this.getPlayers()),
5,
modifier -> modifier.message = new TitleMessage(fadeIn, stay)
.subtitle(subtitleMessage -> subtitleMessage
.appendTranslated("common#startIn", NamedTextColor.DARK_GREEN)
.appendSpace()
.appendStatic(Component.text(modifier.timeLeft, NamedTextColor.GREEN))
.appendSpace()
.appendTranslated(modifier.timeLeft == 1 ? "common#second" : "common#seconds", NamedTextColor.DARK_GREEN)
)
);
}
public void startAccessor() {
this.start();
}
@Override
protected void start() {
score.setInstance(this);
score.attachListeners();
this.score.setInstance(this);
this.score.attachListeners();
countdownStart().thenRun(super::start);
this.countdownStart().thenRun(super::start);
}
@Override
public void stop() {
isRunning = false;
this.isRunning = false;
this.onStop();
getScore().insertRemainingPlayers(getPlayers());
this.publishScore(getScore());
this.getScore().insertRemainingPlayers(this.getPlayers());
this.publishScore(this.getScore());
countdownUnload();
this.countdownUnload();
}
private void countdownUnload() {
new TitleMessage(Duration.ofSeconds(1)).appendTranslated("score#done").send(getPlayers());
scheduler().scheduleTask(this::unload, TaskSchedule.seconds(5), TaskSchedule.stop());
new TitleMessage(Duration.ofSeconds(1)).appendTranslated("score#done").send(this.getPlayers());
this.scheduler().scheduleTask(this::unload, TaskSchedule.seconds(5), TaskSchedule.stop());
}
}
@@ -5,12 +5,12 @@ import java.util.ArrayList;
public class ConfigManager {
private final ArrayList<Option<?>> items = new ArrayList<>();
public ConfigManager addOption(Option option) {
items.add(option);
public ConfigManager addOption(Option<?> option) {
this.items.add(option);
return this;
}
public ArrayList<Option<?>> getAll() {
return items;
return this.items;
}
}
@@ -4,20 +4,19 @@ import eu.mhsl.minenet.minigames.instance.game.Game;
import eu.mhsl.minenet.minigames.instance.game.stateless.config.restriction.Restriction;
import eu.mhsl.minenet.minigames.instance.game.stateless.config.restriction.RestrictionData;
import eu.mhsl.minenet.minigames.instance.game.stateless.config.restriction.RestrictionHandler;
import eu.mhsl.minenet.minigames.instance.room.Room;
import eu.mhsl.minenet.minigames.message.component.TranslatedComponent;
import eu.mhsl.minenet.minigames.shared.inventory.InteractableInventory;
import eu.mhsl.minenet.minigames.util.InventoryItemAlignment;
import eu.mhsl.minenet.minigames.instance.room.Room;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import net.minestom.server.event.EventListener;
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;
@@ -45,7 +44,7 @@ public class GameConfigurationInventory extends InteractableInventory {
ConfigManager config = factory.configuration();
setClickableItem(
this.setClickableItem(
ItemStack.builder(Material.RED_WOOL)
.customName(
TranslatedComponent.byId("common#back")
@@ -58,9 +57,9 @@ public class GameConfigurationInventory extends InteractableInventory {
true
);
setDummyItem(Material.BLACK_STAINED_GLASS_PANE,1);
this.setDummyItem(Material.BLACK_STAINED_GLASS_PANE, 1);
setDummyItem(
this.setDummyItem(
ItemStack.builder(Material.NAME_TAG)
.customName(
factory.name().setColor(NamedTextColor.GOLD).getAssembled(p)
@@ -69,16 +68,16 @@ public class GameConfigurationInventory extends InteractableInventory {
4
);
setDummyItem(Material.BLACK_STAINED_GLASS_PANE,7);
this.setDummyItem(Material.BLACK_STAINED_GLASS_PANE, 7);
updatePlayButton();
this.updatePlayButton();
for(int i = 9; i <= 17; i++) {
setDummyItem(Material.BLACK_STAINED_GLASS_PANE, i);
this.setDummyItem(Material.BLACK_STAINED_GLASS_PANE, i);
}
if(config == null) {
setDummyItem(
this.setDummyItem(
ItemStack.builder(Material.BARRIER)
.customName(
TranslatedComponent.byId("room#noOption").setColor(NamedTextColor.RED).getAssembled(p)
@@ -95,9 +94,9 @@ public class GameConfigurationInventory extends InteractableInventory {
InventoryItemAlignment itemAlignment = new InventoryItemAlignment(config.getAll().size(), 3);
for(Option<?> item : config.getAll()) {
InventoryItemAlignment.ItemOffset current = itemAlignment.next();
map.put(offset + current.get(), item);
this.map.put(offset + current.get(), item);
setDummyItem(
this.setDummyItem(
item.getCurrent(p),
offset + current.get()
);
@@ -107,53 +106,53 @@ 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(!map.containsKey(slot)) return;
if(!this.map.containsKey(slot)) return;
Option<?> item = map.get(slot);
setDummyItem(
item.getNext(p),
Option<?> item = this.map.get(slot);
this.setDummyItem(
item.getNext(this.p),
slot
);
update();
this.update();
}
private void updatePlayButton() {
RestrictionHandler restrictionHandler = factory.globalRestrictions();
RestrictionData restrictionData = new RestrictionData(room);
RestrictionHandler restrictionHandler = this.factory.globalRestrictions();
RestrictionData restrictionData = new RestrictionData(this.room);
if(restrictionHandler.canPlay(restrictionData)) {
setClickableItem(
ItemStack.builder(restrictionHandler.getWarnings(restrictionData).size() > 0 ? Material.YELLOW_WOOL : Material.GREEN_WOOL)
.customName(TranslatedComponent.byId("restriction#success").setColor(NamedTextColor.GREEN).getAssembled(p))
.lore(restrictionHandler.getWarnings(restrictionData).stream().map(translatedComponent -> translatedComponent.getAssembled(p)).collect(Collectors.toList()))
this.setClickableItem(
ItemStack.builder(!restrictionHandler.getWarnings(restrictionData).isEmpty() ? Material.YELLOW_WOOL : Material.GREEN_WOOL)
.customName(TranslatedComponent.byId("restriction#success").setColor(NamedTextColor.GREEN).getAssembled(this.p))
.lore(restrictionHandler.getWarnings(restrictionData).stream().map(translatedComponent -> translatedComponent.getAssembled(this.p)).collect(Collectors.toList()))
.build(),
8,
itemClick -> Game.initialize(factory, map.values().stream().toList(), itemClick.getPlayer()),
itemClick -> Game.initialize(this.factory, this.map.values().stream().toList(), itemClick.getPlayer()),
true
);
} else {
setClickableItem(
this.setClickableItem(
ItemStack.builder(Material.RED_WOOL)
.customName(TranslatedComponent.byId("restriction#fail").setColor(NamedTextColor.RED).getAssembled(p))
.customName(TranslatedComponent.byId("restriction#fail").setColor(NamedTextColor.RED).getAssembled(this.p))
.lore(
restrictionHandler.getRestrictions()
.stream()
.filter(iRestriction -> iRestriction.calculate(restrictionData).type().equals(Restriction.Type.FAIL))
.map(iRestriction -> iRestriction.calculate(restrictionData).description().getAssembled(p))
.map(iRestriction -> iRestriction.calculate(restrictionData).description().getAssembled(this.p))
.collect(Collectors.toList()))
.build(),
8,
itemClick -> {
if(restrictionHandler.canPlay(restrictionData)) {
updatePlayButton();
this.updatePlayButton();
return;
}
itemClick.getPlayer().playSound(Sound.sound(SoundEvent.ENTITY_SILVERFISH_DEATH, Sound.Source.AMBIENT, 1f, 1f));
@@ -163,6 +162,6 @@ public class GameConfigurationInventory extends InteractableInventory {
}
update();
this.update();
}
}
@@ -12,9 +12,11 @@ import java.util.Map;
public interface GameFactory {
TranslatedComponent name();
default ConfigManager configuration() {
return null;
}
default RestrictionHandler globalRestrictions() {
return new RestrictionHandler();
}
@@ -22,6 +24,7 @@ public interface GameFactory {
default Material symbol() {
return Material.GRASS_BLOCK;
}
default TranslatedComponent description() {
return TranslatedComponent.byId("GameFactory#missingDescription");
}
@@ -29,17 +32,17 @@ public interface GameFactory {
Game manufacture(Room parent, Map<String, Option<?>> configuration) throws Exception;
default Game manufacture(Room parent, List<Option<?>> configuration) throws Exception {
if(configuration == null) return manufacture(parent);
if(configuration == null) return this.manufacture(parent);
Map<String, Option<?>> cnf = new HashMap<>();
configuration.forEach(option -> cnf.put(option.getId(), option));
return manufacture(parent, cnf);
return this.manufacture(parent, cnf);
}
default Game manufacture(Room parent) throws Exception {
if(this.configuration() == null) return manufacture(parent, List.of());
if(this.configuration() == null) return this.manufacture(parent, List.of());
return manufacture(parent, this.configuration().getAll());
return this.manufacture(parent, this.configuration().getAll());
}
}
@@ -12,12 +12,12 @@ import java.util.List;
import java.util.Objects;
public abstract class Option<T> {
private RestrictionHandler restrictionHandler;
private final Material item;
private final TranslatedComponent name;
private final String id;
protected T currentValue;
private final List<T> options;
protected T currentValue;
private RestrictionHandler restrictionHandler;
private int pointer = 0;
public Option(String id, Material item, TranslatedComponent name, List<T> options) {
@@ -26,46 +26,45 @@ public abstract class Option<T> {
this.name = name;
this.options = options;
currentValue = options.getFirst();
this.currentValue = options.getFirst();
}
public RestrictionHandler getRestrictionHandler() {
return this.restrictionHandler;
}
public void setRestrictionHandler(RestrictionHandler restrictionHandler) {
this.restrictionHandler = restrictionHandler;
}
public RestrictionHandler getRestrictionHandler() {
return restrictionHandler;
}
public ItemStack getNext(Player p) {
if(++pointer >= options.size()) pointer = 0;
currentValue = options.get(pointer);
return getCurrent(p);
if(++this.pointer >= this.options.size()) this.pointer = 0;
this.currentValue = this.options.get(this.pointer);
return this.getCurrent(p);
}
public ItemStack getCurrent(Player p) {
String value = options.get(pointer).toString();
return ItemStack.builder(item)
.customName(name.getAssembled(p))
String value = this.options.get(this.pointer).toString();
return ItemStack.builder(this.item)
.customName(this.name.getAssembled(p))
.lore(TranslatedComponent.byId("optionCommon#value").setColor(NamedTextColor.GOLD).getAssembled(p)
.append(Component.text(": ")).append(Component.text(value)))
.build();
}
public int getAsInt() {
return Integer.parseInt(getAsString());
return Integer.parseInt(this.getAsString());
}
public boolean getAsBoolean() {
return Objects.equals(getAsString(), "true") || Objects.equals(getAsString(), "1");
return Objects.equals(this.getAsString(), "true") || Objects.equals(this.getAsString(), "1");
}
public String getAsString() {
return currentValue.toString();
return this.currentValue.toString();
}
public String getId() {
return id;
return this.id;
}
}
@@ -22,11 +22,11 @@ public class RestrictionHandler {
}
public List<IRestriction> getRestrictions() {
return restrictions;
return this.restrictions;
}
public boolean canPlay(RestrictionData data) {
if(restrictions.size() < 1) return true;
if(this.restrictions.isEmpty()) return true;
return this.restrictions.stream()
.noneMatch(iRestriction -> iRestriction.calculate(data).type().equals(Restriction.Type.FAIL));
}
@@ -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;
@@ -27,26 +28,25 @@ import java.util.concurrent.CompletableFuture;
public class AcidRain extends StatelessGame {
final private int radius = 20;
private int generationOffset = 0;
final private int roofHeight = 55;
private int difficulty = 0;
final JNoise noise = JNoise.newBuilder()
.fastSimplex()
.setSeed(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;
private int generationOffset = 0;
private int difficulty = 0;
public AcidRain() {
super(Dimension.OVERWORLD.key, "acidRain", new LastWinsScore());
setGenerator(
new CircularPlateTerrainGenerator(radius)
this.setGenerator(
new CircularPlateTerrainGenerator(this.radius)
.setPlateHeight(50)
.setPlatePallet(BlockPallet.STONE)
);
eventNode().addListener(PlayerTickEvent.class, this::onPlayerTick);
this.eventNode().addListener(PlayerTickEvent.class, this::onPlayerTick);
}
@Override
@@ -61,39 +61,40 @@ public class AcidRain extends StatelessGame {
MinecraftServer.getSchedulerManager().submitTask(() -> {
getPlayers().forEach(player -> {
player.sendPacket(new ParticlePacket(Particle.SNEEZE, 0, 60, 0, radius, radius, radius, 1f, 500));
player.sendPacket(new ParticlePacket(Particle.ITEM_SLIME, 0, 60, 0, radius, radius, radius, 1f, 500));
this.getPlayers().forEach(player -> {
player.sendPacket(new ParticlePacket(Particle.SNEEZE, 0, 60, 0, this.radius, this.radius, this.radius, 1f, 500));
player.sendPacket(new ParticlePacket(Particle.ITEM_SLIME, 0, 60, 0, this.radius, this.radius, this.radius, 1f, 500));
});
if(!isRunning) return TaskSchedule.stop();
if(!this.isRunning) return TaskSchedule.stop();
return TaskSchedule.millis(100);
}, ExecutionType.TICK_END);
MinecraftServer.getSchedulerManager().submitTask(() -> {
generationOffset++;
generatePlatform(generationOffset);
this.generationOffset++;
this.generatePlatform(this.generationOffset);
if(!isRunning) return TaskSchedule.stop();
return TaskSchedule.millis((long) NumberUtil.map(50 - difficulty, 0, 50, 100, 1000));
if(!this.isRunning) return TaskSchedule.stop();
return TaskSchedule.millis((long) NumberUtil.map(50 - this.difficulty, 0, 50, 100, 1000));
}, ExecutionType.TICK_END);
MinecraftServer.getSchedulerManager().submitTask(() -> {
difficulty++;
this.difficulty++;
if(difficulty >= 50) return TaskSchedule.stop();
if(this.difficulty >= 50) return TaskSchedule.stop();
return TaskSchedule.seconds(3);
});
}
@Override
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
if(playerMoveEvent.getNewPosition().y() < 50) playerDeath(playerMoveEvent.getPlayer());
if(playerMoveEvent.getNewPosition().y() < 50) this.playerDeath(playerMoveEvent.getPlayer());
}
private void onPlayerTick(PlayerTickEvent playerTickEvent) {
if(isBeforeBeginning) return;
if(getBlock(playerTickEvent.getPlayer().getPosition().withY(roofHeight)).isAir()) playerDeath(playerTickEvent.getPlayer());
if(this.isBeforeBeginning) return;
if(this.getBlock(playerTickEvent.getPlayer().getPosition().withY(this.roofHeight)).isAir())
this.playerDeath(playerTickEvent.getPlayer());
}
private void generatePlatform(long offset) {
@@ -102,19 +103,20 @@ public class AcidRain extends StatelessGame {
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
for(int x = -radius; x <= radius; x++) {
for(int z = -radius; z <= radius; z++) {
batch.setBlock(x, roofHeight, z, Block.AIR);
batch.setBlock(x, this.roofHeight, z, Block.AIR);
if(new Pos(x, 0, z).distance(new Pos(0, 0, 0)) > radius) continue;
if(noise.getNoise(x + offset, z + offset) > 0.4) continue;
if(this.noise.evaluateNoise(x + offset, z + offset) > 0.4) continue;
batch.setBlock(x, roofHeight, z, Block.OAK_PLANKS);
batch.setBlock(x, this.roofHeight, z, Block.OAK_PLANKS);
}
}
BatchUtil.loadAndApplyBatch(batch, this, () -> {});
BatchUtil.loadAndApplyBatch(batch, this, () -> {
});
}
private void playerDeath(Player p) {
p.setGameMode(GameMode.SPECTATOR);
getScore().insertResult(p);
this.getScore().insertResult(p);
}
@Override
@@ -1,8 +1,8 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.anvilRun;
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.instance.Dimension;
import eu.mhsl.minenet.minigames.util.BatchUtil;
import eu.mhsl.minenet.minigames.world.generator.terrain.CircularPlateTerrainGenerator;
import net.minestom.server.MinecraftServer;
@@ -27,10 +27,10 @@ class AnvilRun extends StatelessGame {
final int spawnHeight = 30;
final int radius;
int anvilsPerSecond;
final int seconds;
final int anvilHeight = 200;
final List<Pos> anvilSpawnPositions = new ArrayList<>();
int anvilsPerSecond;
public AnvilRun(int radius, int seconds) {
super(Dimension.OVERWORLD.key, "Anvil Run", new LastWinsScore());
@@ -38,25 +38,25 @@ class AnvilRun extends StatelessGame {
this.seconds = seconds;
this.setGenerator(new CircularPlateTerrainGenerator(radius));
eventNode().addListener(EntityTickEvent.class, this::onEntityTick);
this.eventNode().addListener(EntityTickEvent.class, this::onEntityTick);
}
@Override
protected void onLoad(@NotNull CompletableFuture<Void> callback) {
AbsoluteBlockBatch batch = new AbsoluteBlockBatch();
for(int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if(new Pos(x, 0, z).distance(new Pos(0, 0, 0)) > radius) continue;
for(int x = -this.radius; x <= this.radius; x++) {
for(int z = -this.radius; z <= this.radius; z++) {
if(new Pos(x, 0, z).distance(new Pos(0, 0, 0)) > this.radius) continue;
anvilSpawnPositions.add(new Pos(x+0.5, anvilHeight, z+0.5));
this.anvilSpawnPositions.add(new Pos(x + 0.5, this.anvilHeight, z + 0.5));
batch.setBlock(x, spawnHeight-1, z, Block.SNOW_BLOCK);
batch.setBlock(x, this.spawnHeight - 1, z, Block.SNOW_BLOCK);
}
}
this.anvilsPerSecond = anvilSpawnPositions.size() / this.seconds;
Collections.shuffle(anvilSpawnPositions);
this.anvilsPerSecond = this.anvilSpawnPositions.size() / this.seconds;
Collections.shuffle(this.anvilSpawnPositions);
BatchUtil.loadAndApplyBatch(batch, this, () -> callback.complete(null));
}
@@ -75,7 +75,7 @@ class AnvilRun extends StatelessGame {
for(int i = 0; i < this.anvilSpawnPositions.size(); i++) {
final int j = i;
scheduler.scheduleTask(
() -> spawnAnvil(this.anvilSpawnPositions.get(j)),
() -> this.spawnAnvil(this.anvilSpawnPositions.get(j)),
TaskSchedule.millis(
(long) Math.floor((double) i / this.anvilsPerSecond * 1000)
),
@@ -87,25 +87,26 @@ class AnvilRun extends StatelessGame {
@Override
protected void onPlayerMove(@NotNull PlayerMoveEvent playerMoveEvent) {
super.onPlayerMove(playerMoveEvent);
if(isBeforeBeginning && playerMoveEvent.getNewPosition().y() < spawnHeight - 2) {
if(this.isBeforeBeginning && playerMoveEvent.getNewPosition().y() < this.spawnHeight - 2) {
playerMoveEvent.setCancelled(true);
playerMoveEvent.getPlayer().teleport(getSpawn());
playerMoveEvent.getPlayer().teleport(this.getSpawn());
return;
}
if(playerMoveEvent.getNewPosition().y() < spawnHeight - 2) getScore().insertResult(playerMoveEvent.getPlayer());
if(playerMoveEvent.getNewPosition().y() < this.spawnHeight - 2)
this.getScore().insertResult(playerMoveEvent.getPlayer());
}
protected void onEntityTick(@NotNull EntityTickEvent entityTickEvent) {
if(!entityTickEvent.getEntity().getEntityType().equals(EntityType.FALLING_BLOCK)) return;
Pos anvilPosition = entityTickEvent.getEntity().getPosition();
if(anvilPosition.y() > spawnHeight + 0.5) return;
if(anvilPosition.y() < spawnHeight - 3) entityTickEvent.getEntity().remove();
if(this.getBlock(anvilPosition.withY(spawnHeight-1)).isAir()) return;
this.setBlock(anvilPosition.withY(spawnHeight-1), Block.AIR);
if(anvilPosition.y() > this.spawnHeight + 0.5) return;
if(anvilPosition.y() < this.spawnHeight - 3) entityTickEvent.getEntity().remove();
if(this.getBlock(anvilPosition.withY(this.spawnHeight - 1)).isAir()) return;
this.setBlock(anvilPosition.withY(this.spawnHeight - 1), Block.AIR);
}
@Override
public Pos getSpawn() {
return new Pos(0, spawnHeight, 0);
return new Pos(0, this.spawnHeight, 0);
}
}
@@ -1,9 +1,9 @@
package eu.mhsl.minenet.minigames.instance.game.stateless.types.anvilRun;
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.ConfigManager;
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;
@@ -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();
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,86 +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);
}
}
}
generateWalls(modifier, start.blockX() + 0, yPos + 1, start.blockZ() + 0);
generateWalls(modifier, start.blockX() + 8, yPos + 1, start.blockZ() + 0);
generateWalls(modifier, start.blockX() + 0, yPos + 1, start.blockZ() + 8);
generateWalls(modifier, start.blockX() + 8, yPos + 1, start.blockZ() + 8);
}
private void generateWalls(UnitModifier modifier, int xPos, int yPos, int zPos) {
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++) {
generatePillar(modifier, x, yPos, zPos, Block.SMOOTH_SANDSTONE);
}
}
if (wall2) {
for (int z = zPos; z < zPos + 8; z++) {
generatePillar(modifier, xPos, yPos, z, Block.SMOOTH_SANDSTONE);
}
}
if (door1 && wall1) {
generatePillar(modifier, xPos + door1pos, yPos, zPos, Block.AIR);
generatePillar(modifier, xPos + door1pos + 1, yPos, zPos, Block.AIR);
}
if (door2 && wall2) {
generatePillar(modifier, xPos, yPos, zPos + door2pos, Block.AIR);
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;
}
}

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