Compare commits

..
25 Commits
Author SHA1 Message Date
MineTec cbce781099 Drop chat locally, chat-bridge on velocity distributes it 2026-10-17 22:41:48 +02:00
MineTec 05f2fcb9da Cycle random stage themes every 50 jumps in the endless part and unload chunks far behind the player 2026-10-10 13:34:41 +02:00
MineTec d4465063d4 Launch more fireworks across the field of view, near and far 2026-10-10 13:28:09 +02:00
MineTec c9da982285 Never generate upward jumps from blocks lower than a full block 2026-10-10 13:24:22 +02:00
MineTec f2b7602321 Add an admin-only /joker command to hand out jokers for testing 2026-10-10 13:17:19 +02:00
MineTec a262010c82 Keep the block display a few ticks after placing the real block to avoid flickering 2026-10-10 13:13:03 +02:00
MineTec 38bb9c6dc8 Add feather fall and spring jokers, drop jokers on checkpoints with 10% chance and only give a totem when starting from zero 2026-10-10 13:09:02 +02:00
MineTec 555f4024e4 Simplify the rescue totem: keep the platform and ladder and disallow breaking blocks 2026-10-10 13:06:36 +02:00
MineTec 7e85a6d229 Compress the stages so the endless part starts at 1000, keep the stage in the action bar permanently and let the bridge cover the next 10 blocks from the current position 2026-10-10 13:03:11 +02:00
MineTec 40bedc3c43 Spawn ambient particles along the course ahead instead of only around the player 2026-10-10 12:57:23 +02:00
MineTec de3578eafe Count skipped checkpoints when jumping past them 2026-10-08 00:53:27 +02:00
MineTec 249cd0c08a Add /reset to start over from scratch and hint at it in the endless stage 2026-10-08 00:48:44 +02:00
MineTec e2c574bf0b Upload the limbo jar to a temp file, verify the checksum and rename it atomically 2026-10-08 00:46:16 +02:00
MineTec 115064cafa Make the underground stages dark from the start 2026-10-08 00:38:09 +02:00
MineTec 806d76e560 Resume from the last checkpoint, celebrate reaching the end with fireworks, continue endlessly afterwards and start the run right on spawn 2026-10-08 00:35:08 +02:00
MineTec 137e162ea4 Give every stage its own time of day with smooth transitions, ambient particles and music 2026-10-08 00:31:48 +02:00
MineTec de6b987ffd Drop a random joker on every diamond block and simplify the generator around an explicit course position 2026-10-08 00:23:17 +02:00
MineTec ef1662b144 Add bridge and rescue totem jokers and fix dark block displays during the transition 2026-10-08 00:17:00 +02:00
MineTec f56a3765c6 Show each player's current score in the tab list 2026-10-08 00:11:28 +02:00
MineTec 9e1edbfc7c Prevent upward jumps from blocks lower than a full block like end portal frames 2026-10-08 00:10:19 +02:00
MineTec 8ba25758be Show the current stage in the action bar instead of a title 2026-10-07 23:56:14 +02:00
MineTec b1950dd664 Keep jumps at natural sprint distance, grow new blocks out of the top of the previous one and show one more preview block 2026-10-07 23:55:27 +02:00
MineTecandClaude Opus 5.5 452f2f3ef7 new blocks grow out of the previous block and glide into place
- new jump blocks spawn as a small block display inside the previous block and
  interpolate position and scale to their target over 8 ticks
- the real block (plus light and particles) is placed when the display arrives,
  the display is removed afterwards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:52:04 +02:00
MineTecandClaude Opus 5.5 0faff2bd0c added difficulty curve and themed stages from overworld to the end
- jumps start easy (2-3 blocks, rarely sideways/upwards) and get harder over the
  first 1000 jumps up to the previous difficulty (3-4 blocks, often sideways/upwards)
- block themes follow a full playthrough: meadow, forest, underground, caves, deepslate,
  nether portal, nether, fortress & bastion, stronghold, portal room, the end (from 1000)
- title and sound when a new stage is reached
- gold/diamond checkpoint markers unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:49:49 +02:00
MineTecandClaude Opus 5.5 8e046c8603 migrated to Gradle and Minestom 2026.10.05-26.2
- Maven -> Gradle 9.6.1 (shadow 9.6.1, Java 25), shadow jar is build/libs/limbo.jar
- Minestom from Maven Central (net.minestom:minestom), speaks Minecraft 26.2 natively
- Velocity forwarding via Auth.Velocity; the secret is no longer hardcoded but read from
  forwarding.secret in the working directory or VELOCITY_FORWARDING_SECRET
- time rate via defaultClock().pause() (new clock API)
- slf4j-simple for logging (Minestom no longer ships a logger)
- removed target/ and Maven files from the repository
- uploadLimbo task copies the jar to the limbo server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:47:40 +02:00
33 changed files with 1422 additions and 289 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
.gradle/
build/
target/
*.iml
.idea/workspace.xml
forwarding.secret
progress.properties
progress.properties.tmp
admins.txt
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>MIXIN</platformType>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
</configuration>
</facet>
</component>
</module>
+48
View File
@@ -0,0 +1,48 @@
plugins {
id 'java'
id 'application'
id 'com.gradleup.shadow' version '9.6.1'
}
group = 'eu.mhsl.minecraft'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation 'net.minestom:minestom:2026.10.05-26.2'
runtimeOnly 'org.slf4j:slf4j-simple:2.0.20'
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
application {
mainClass = 'eu.mhsl.minecraft.endlessjumper.EndlessJumper'
}
shadowJar {
archiveFileName = 'limbo.jar'
mergeServiceFiles()
}
// Hochladen auf den Limbo: ./gradlew uploadLimbo, danach `sudo systemctl restart limbo`.
// Erst in eine Temp-Datei, Prüfsumme vergleichen, dann atomar umbenennen: ein abgebrochener Upload
// lässt das laufende JAR unberührt, und der laufende Prozess behält bis zum Neustart sein altes JAR.
tasks.register('uploadLimbo') {
dependsOn shadowJar
doLast {
def jar = shadowJar.archiveFile.get().asFile
def checksum = jar.bytes.digest('SHA-256')
providers.exec {
commandLine 'scp', jar.absolutePath, 'minecraft@10.20.9.3:/home/minecraft/limbo/limbo.jar.new'
}.result.get()
providers.exec {
commandLine 'ssh', 'minecraft@10.20.9.3',
"cd ~/limbo && echo '${checksum} limbo.jar.new' | sha256sum -c --quiet && mv -f limbo.jar.new limbo.jar"
}.result.get()
}
}
+1 -2
View File
@@ -1,2 +1 @@
mvn -f pom.xml clean package
./gradlew shadowJar
-44
View File
@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>EndlessJumper</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>eu.mhsl.minecraft.endlessjumper.EndlessJumper</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spongepowered</id>
<url>https://repo.spongepowered.org/maven</url>
</repository>
<repository>
<id>jitpack</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
</properties>
</project>
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 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.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# 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" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
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,
# 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 \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
: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 %*
: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
-59
View File
@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>EndlessJumper</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<repositories>
<repository>
<id>spongepowered</id>
<url>https://repo.spongepowered.org/maven</url>
</repository>
<repository>
<id>jitpack</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>net.minestom</groupId>
<artifactId>minestom-snapshots</artifactId>
<version>d707b0674f</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>eu.mhsl.minecraft.endlessjumper.EndlessJumper</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'EndlessJumper'
@@ -0,0 +1,32 @@
package eu.mhsl.minecraft.endlessjumper;
import net.minestom.server.entity.Player;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Admins aus admins.txt im Arbeitsverzeichnis: eine UUID pro Zeile, „#“ leitet einen Kommentar ein.
* Der Limbo kennt kein LuckPerms, die Datei wird aus dessen Admin-Gruppen erzeugt.
*/
public class Admins {
private static final Path FILE = Path.of("admins.txt");
private static Set<UUID> admins = Set.of();
public static void load() throws IOException {
if(!Files.exists(FILE)) return;
admins = Files.readAllLines(FILE).stream()
.map(line -> line.replaceFirst("#.*", "").trim())
.filter(line -> !line.isEmpty())
.map(UUID::fromString)
.collect(Collectors.toUnmodifiableSet());
}
public static boolean is(Player player) {
return admins.contains(player.getUuid());
}
}
@@ -1,9 +0,0 @@
package eu.mhsl.minecraft.endlessjumper;
import net.minestom.server.coordinate.Pos;
public class BlockMath {
public static Pos roundToBlock(Pos p) {
return new Pos(p.blockX(), p.blockY(), p.blockZ());
}
}
@@ -1,88 +1,153 @@
package eu.mhsl.minecraft.endlessjumper;
import eu.mhsl.minecraft.endlessjumper.generator.JumpGenerator;
import net.minestom.server.Auth;
import net.minestom.server.MinecraftServer;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.command.builder.arguments.ArgumentWord;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
import net.minestom.server.entity.PlayerSkin;
import net.minestom.server.event.GlobalEventHandler;
import net.minestom.server.event.item.ItemDropEvent;
import net.minestom.server.event.item.PickupItemEvent;
import net.minestom.server.event.player.AsyncPlayerConfigurationEvent;
import net.minestom.server.event.player.PlayerBlockBreakEvent;
import net.minestom.server.event.player.PlayerBlockPlaceEvent;
import net.minestom.server.event.player.PlayerChatEvent;
import net.minestom.server.event.player.PlayerDisconnectEvent;
import net.minestom.server.event.player.PlayerMoveEvent;
import net.minestom.server.extras.velocity.VelocityProxy;
import net.minestom.server.event.player.PlayerSpawnEvent;
import net.minestom.server.event.player.PlayerSwapItemEvent;
import net.minestom.server.event.player.PlayerUseItemEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.instance.InstanceManager;
import net.minestom.server.instance.block.Block;
import net.minestom.server.timer.TaskSchedule;
import java.util.HashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class EndlessJumper {
public static void main(String[] args) {
MinecraftServer server = MinecraftServer.init();
InstanceManager instanceManager = MinecraftServer.getInstanceManager();
private static final Pos START = new Pos(0, 100, 0);
private static final Pos SPAWN = START.add(0.5, 1, 0.5);
private static final Map<Player, JumpGenerator> games = new ConcurrentHashMap<>();
public static void main(String[] args) throws IOException {
MinecraftServer server = MinecraftServer.init(new Auth.Velocity(readForwardingSecret()));
Progress.load();
Admins.load();
GlobalEventHandler eventHandler = MinecraftServer.getGlobalEventHandler();
PlayerSkin skin = PlayerSkin.fromUsername("Notch");
HashMap<Player, JumpGenerator> games = new HashMap<>();
eventHandler.addListener(AsyncPlayerConfigurationEvent.class, event -> {
final Player p = event.getPlayer();
p.setRespawnPoint(new Pos(0.5,101.5,0.5));
final InstanceContainer world = instanceManager.createInstanceContainer();
world.setGenerator(new TerrainGenerator());
Pos startingAt = new Pos(0,100,0);
world.setBlock(startingAt, Block.DIAMOND_BLOCK);
world.setTimeRate(0);
world.setTime(18000);
p.setRespawnPoint(SPAWN);
p.setSkin(skin);
event.setSpawningInstance(createWorld());
});
event.setSpawningInstance(world);
MinecraftServer.getSchedulerManager().scheduleTask(
() -> games.put(p, new JumpGenerator(startingAt, p, world)),
TaskSchedule.seconds(3),
TaskSchedule.stop()
);
eventHandler.addListener(PlayerSpawnEvent.class, event -> {
if(!event.isFirstSpawn()) return;
Player p = event.getPlayer();
p.setCanPickupItem(true);
ScoreTab.join(p);
Jokers.clear(p);
// sobald der Spieler in der Welt steht, geht es los
games.put(p, new JumpGenerator(START, Progress.checkpoint(p.getUuid()), p, p.getInstance()));
});
eventHandler.addListener(PlayerMoveEvent.class, event -> {
Player p = event.getPlayer();
if(!games.containsKey(p)) {
event.setCancelled(true);
return;
}
JumpGenerator generator = games.get(p);
//reset when falling down
if(p.getPosition().y() < generator.getNextLanding().y()-5 && p.getPosition().y() < generator.getCheckpoint().y()) generator.reset();
Pos p1 = BlockMath.roundToBlock(event.getNewPosition().sub(0.0,1.0,0.0));
Pos p2 = BlockMath.roundToBlock(event.getNewPosition().sub(0.0, 2.0, 0.0));
for(int i = 0; i <= generator.getPreview(); i++) {
Pos target = generator.getBlocks().get(generator.getBlocks().size()-i-1);
if(p1.sameBlock(target) || p2.sameBlock(target)) generator.generateNext();
}
//if(p.getPosition().z() > target.z()+2) generator.highlight();
JumpGenerator generator = games.get(event.getPlayer());
if(generator == null) event.setCancelled(true);
else generator.onMove(event.getNewPosition());
});
eventHandler.addListener(PlayerBlockBreakEvent.class, event -> event.setCancelled(true));
eventHandler.addListener(PlayerBlockPlaceEvent.class, event -> event.setCancelled(true));
eventHandler.addListener(ItemDropEvent.class, event -> event.setCancelled(true));
eventHandler.addListener(PlayerSwapItemEvent.class, event -> event.setCancelled(true));
// den Chat verteilt Velocity (chat-bridge) an das ganze Netzwerk, hier würde er doppelt stehen
eventHandler.addListener(PlayerChatEvent.class, event -> event.setCancelled(true));
eventHandler.addListener(PlayerDisconnectEvent.class, event -> games.remove(event.getPlayer()));
// der Client meldet Rechtsklicks auch auf Blöcke als Item-Nutzung, solange der Block sie nicht
// selbst verarbeitet (auf der Strecke nie) – ein Event genügt, Joker werden nicht doppelt verbraucht
eventHandler.addListener(PlayerUseItemEvent.class, event -> {
JumpGenerator generator = games.get(event.getPlayer());
Jokers joker = Jokers.of(event.getItemStack());
if(generator != null && joker != null && generator.useJoker(joker)) joker.take(event.getPlayer());
});
eventHandler.addListener(PickupItemEvent.class, event -> {
Jokers joker = Jokers.of(event.getItemStack());
if(event.getLivingEntity() instanceof Player player && joker != null) joker.add(player);
else event.setCancelled(true);
});
eventHandler.addListener(PlayerDisconnectEvent.class, event -> {
Player p = event.getPlayer();
games.remove(p);
ScoreTab.leave(p);
// jede Partie hat eine eigene Welt: nach dem Verlassen freigeben
releaseLater(p.getInstance());
});
Command reset = new Command("reset");
reset.setDefaultExecutor((sender, context) -> {
if(sender instanceof Player player) resetGame(player);
});
MinecraftServer.getCommandManager().register(reset);
// nur für Admins (admins.txt): Joker zum Testen direkt geben
Command joker = new Command("joker");
joker.setCondition((sender, commandString) -> sender instanceof Player player && Admins.is(player));
ArgumentWord type = ArgumentType.Word("typ").from(Jokers.keys());
joker.addSyntax((sender, context) -> Jokers.byKey(context.get(type)).add((Player) sender), type);
MinecraftServer.getCommandManager().register(joker);
VelocityProxy.enable("iph0YHK9jNWZ");
server.start("0.0.0.0", 25501);
}
private static InstanceContainer createWorld() {
InstanceContainer world = MinecraftServer.getInstanceManager().createInstanceContainer();
world.setBlock(START, Block.DIAMOND_BLOCK);
// Tageszeit steuert der jeweilige Abschnitt
world.defaultClock().pause();
return world;
}
private static void releaseLater(Instance world) {
if(world != null) MinecraftServer.getSchedulerManager().scheduleNextTick(() -> MinecraftServer.getInstanceManager().unregisterInstance(world));
}
/** /reset: Fortschritt, Strecke, Score und Joker zurücksetzen und in einer frischen Welt bei 0 beginnen. */
private static void resetGame(Player player) {
JumpGenerator old = games.remove(player);
if(old != null) old.close();
Progress.reset(player.getUuid());
Instance oldWorld = player.getInstance();
InstanceContainer world = createWorld();
player.setVelocity(Vec.ZERO);
player.setInstance(world, SPAWN).thenRun(() -> {
Jokers.clear(player);
games.put(player, new JumpGenerator(START, 0, player, world));
releaseLater(oldWorld);
});
}
// Wie bei Velocity: Secret aus forwarding.secret im Arbeitsverzeichnis, alternativ per Umgebungsvariable
private static String readForwardingSecret() throws IOException {
String fromEnv = System.getenv("VELOCITY_FORWARDING_SECRET");
if(fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim();
Path file = Path.of("forwarding.secret");
if(!Files.exists(file))
throw new IllegalStateException("forwarding.secret fehlt im Arbeitsverzeichnis (oder VELOCITY_FORWARDING_SECRET setzen)");
return Files.readString(file).trim();
}
}
@@ -0,0 +1,87 @@
package eu.mhsl.minecraft.endlessjumper;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.minestom.server.entity.Player;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import java.util.Random;
/**
* Joker-Items in der Hotbar, die Anzahl ist die Stapelgröße. Es gibt sie zum Einsammeln auf der
* Strecke; wer bei 0 beginnt, bekommt ein Totem. Das Totem wirkt automatisch, die anderen per Rechtsklick (siehe JumpGenerator#useJoker).
*/
public enum Jokers {
BRIDGE("bruecke", 0, Material.BRICK, "Brücke", NamedTextColor.AQUA, "Rechtsklick: Weg über die nächsten 10 Blöcke"),
FEATHER_FALL("federfall", 1, Material.FEATHER, "Federfall", NamedTextColor.WHITE, "Rechtsklick: 15 Sekunden langsames Fallen"),
SPRING("sprungfeder", 2, Material.RABBIT_FOOT, "Sprungfeder", NamedTextColor.GREEN, "Rechtsklick: die nächsten 10 Sprünge mit Sprungkraft"),
TOTEM("totem", 8, Material.TOTEM_OF_UNDYING, "Rettungstotem", NamedTextColor.GOLD, "Rettet dich automatisch beim Runterfallen");
// Name im Befehl /joker (Brigadier erlaubt keine Umlaute)
private final String key;
private final int slot;
private final String name;
private final ItemStack item;
Jokers(String key, int slot, Material material, String name, TextColor color, String lore) {
this.key = key;
this.slot = slot;
this.name = name;
this.item = ItemStack.builder(material)
.customName(Component.text(name, color).decoration(TextDecoration.ITALIC, false))
.lore(Component.text(lore, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false))
.build();
}
public ItemStack item() {
return this.item;
}
public boolean matches(ItemStack stack) {
return stack.material() == this.item.material();
}
public void add(Player player) {
player.getInventory().replaceItemStack(this.slot, current -> this.matches(current) ? current.withAmount(a -> a + 1) : this.item);
player.sendMessage(Component.text("+1 " + this.name, NamedTextColor.GOLD));
}
/** Nimmt einen Joker weg; false, wenn keiner mehr da ist. */
public boolean take(Player player) {
if(!this.matches(player.getInventory().getItemStack(this.slot))) return false;
player.getInventory().replaceItemStack(this.slot, current -> current.consume(1));
return true;
}
/** Zum Start (und nach /reset) gibt es keine Joker. */
public static void clear(Player player) {
for(Jokers joker : values()) player.getInventory().setItemStack(joker.slot, ItemStack.AIR);
}
public static Jokers random(Random random) {
return values()[random.nextInt(values().length)];
}
public static String[] keys() {
String[] keys = new String[values().length];
for(Jokers joker : values()) keys[joker.ordinal()] = joker.key;
return keys;
}
public static Jokers byKey(String key) {
for(Jokers joker : values()) {
if(joker.key.equals(key)) return joker;
}
return null;
}
public static Jokers of(ItemStack stack) {
for(Jokers joker : values()) {
if(joker.matches(stack)) return joker;
}
return null;
}
}
@@ -0,0 +1,59 @@
package eu.mhsl.minecraft.endlessjumper;
import net.minestom.server.MinecraftServer;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Letzter erreichter Checkpoint pro Spieler, damit man beim nächsten Besuch dort weiterspielt.
* Liegt als progress.properties im Arbeitsverzeichnis; für ein neues Projekt einfach löschen.
*/
public class Progress {
private static final Path FILE = Path.of("progress.properties");
private static final Path TEMP = Path.of("progress.properties.tmp");
// Properties ist synchronisiert; geschrieben wird nacheinander außerhalb des Server-Ticks
private static final Properties checkpoints = new Properties();
private static final ExecutorService writer = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory());
public static void load() throws IOException {
if(!Files.exists(FILE)) return;
try(Reader reader = Files.newBufferedReader(FILE)) {
checkpoints.load(reader);
}
}
public static int checkpoint(UUID player) {
return Integer.parseInt(checkpoints.getProperty(player.toString(), "0"));
}
public static void save(UUID player, int checkpoint) {
checkpoints.setProperty(player.toString(), String.valueOf(checkpoint));
writer.execute(Progress::write);
}
public static void reset(UUID player) {
checkpoints.remove(player.toString());
writer.execute(Progress::write);
}
/** Erst in eine Temp-Datei, dann atomar umbenennen: ein Absturz hinterlässt nie eine halbe Datei. */
private static void write() {
try {
try(Writer out = Files.newBufferedWriter(TEMP)) {
checkpoints.store(out, "EndlessJumper: letzter Checkpoint pro Spieler");
}
Files.move(TEMP, FILE, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch(IOException e) {
MinecraftServer.getExceptionManager().handleException(e);
}
}
}
@@ -0,0 +1,27 @@
package eu.mhsl.minecraft.endlessjumper;
import net.minestom.server.entity.Player;
import net.minestom.server.network.packet.server.play.ScoreboardObjectivePacket;
import net.minestom.server.scoreboard.TabList;
/**
* Zeigt den aktuellen Fortschritt jedes Spielers als Zahl in der Tab-Liste.
* Der Fortschritt ist zugleich das Level des Spielers, daher braucht es keinen eigenen Speicher.
*/
public class ScoreTab {
private static final TabList TAB_LIST = new TabList("jumps", ScoreboardObjectivePacket.Type.INTEGER);
public static void join(Player player) {
TAB_LIST.addViewer(player);
// dem neuen Spieler auch die Scores aller anderen zeigen
TAB_LIST.getViewers().forEach(viewer -> TAB_LIST.updateScore(viewer, viewer.getLevel()));
}
public static void update(Player player, int score) {
if(TAB_LIST.getViewers().contains(player)) TAB_LIST.updateScore(player, score);
}
public static void leave(Player player) {
TAB_LIST.removeViewer(player);
}
}
@@ -1,12 +0,0 @@
package eu.mhsl.minecraft.endlessjumper;
import net.minestom.server.instance.generator.GenerationUnit;
import net.minestom.server.instance.generator.Generator;
import org.jetbrains.annotations.NotNull;
public class TerrainGenerator implements Generator {
@Override
public void generate(@NotNull GenerationUnit unit) {
}
}
@@ -1,22 +1,115 @@
package eu.mhsl.minecraft.endlessjumper.generator;
import net.minestom.server.instance.block.Block;
import net.minestom.server.particle.Particle;
import net.minestom.server.sound.SoundEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
/**
* Abschnitte entlang des Fortschritts: einmal durch das komplette Spiel, von der Wiese über
* Untergrund, Nether und Stronghold bis ins End; ab Sprung 1000 geht es endlos weiter, alle 50 Sprünge
* mit einem zufälligen Thema aus diesen Abschnitten. Jeder Abschnitt hat eigene
* Blöcke und eine eigene Stimmung (Tageszeit, Partikel, Musik).
*/
public class BlockPalette {
public static Block getRandom(int seed) {
Random r = new Random(seed);
ArrayList<Block> blocks = new ArrayList<>();
/** @param ambient Partikel um den Spieler, {@code null} für keine */
public record Stage(int from, String name, long time, Particle ambient, SoundEvent music, List<Block> blocks) {
}
blocks.add(Block.COPPER_BLOCK);
blocks.add(Block.CUT_COPPER);
blocks.add(Block.WEATHERED_COPPER);
blocks.add(Block.WEATHERED_CUT_COPPER);
blocks.add(Block.OXIDIZED_COPPER);
blocks.add(Block.OXIDIZED_CUT_COPPER);
private static final long MORNING = 1000, NOON = 6000, NIGHT = 18000;
return blocks.get(r.nextInt(0, blocks.size()));
private static final List<Stage> STAGES = List.of(
new Stage(0, "Wiese", MORNING, null, SoundEvent.MUSIC_OVERWORLD_MEADOW, List.of(
Block.GRASS_BLOCK, Block.GRASS_BLOCK, Block.MOSS_BLOCK, Block.PODZOL, Block.ROOTED_DIRT
)),
new Stage(70, "Wald", NOON, Particle.CHERRY_LEAVES, SoundEvent.MUSIC_OVERWORLD_FOREST, List.of(
Block.OAK_LOG, Block.BIRCH_LOG, Block.SPRUCE_LOG, Block.CHERRY_LOG,
Block.OAK_LEAVES, Block.BIRCH_LEAVES, Block.SPRUCE_LEAVES, Block.CHERRY_LEAVES
)),
new Stage(140, "Untergrund", NIGHT, null, SoundEvent.MUSIC_OVERWORLD_DRIPSTONE_CAVES, List.of(
Block.DIRT, Block.COARSE_DIRT, Block.STONE, Block.STONE, Block.ANDESITE,
Block.GRANITE, Block.DIORITE, Block.COBBLESTONE
)),
new Stage(220, "Höhlen", NIGHT, Particle.DRIPPING_WATER, SoundEvent.MUSIC_OVERWORLD_LUSH_CAVES, List.of(
Block.STONE, Block.COAL_ORE, Block.IRON_ORE, Block.COPPER_ORE,
Block.GOLD_ORE, Block.REDSTONE_ORE, Block.LAPIS_ORE
)),
new Stage(300, "Tiefenschiefer", NIGHT, Particle.SCULK_SOUL, SoundEvent.MUSIC_OVERWORLD_DEEP_DARK, List.of(
Block.DEEPSLATE, Block.DEEPSLATE, Block.TUFF, Block.DEEPSLATE_DIAMOND_ORE,
Block.DEEPSLATE_IRON_ORE, Block.AMETHYST_BLOCK
)),
new Stage(390, "Netherportal", NIGHT, Particle.PORTAL, SoundEvent.MUSIC_NETHER_NETHER_WASTES, List.of(
Block.OBSIDIAN, Block.OBSIDIAN, Block.CRYING_OBSIDIAN
)),
new Stage(410, "Nether", NIGHT, Particle.CRIMSON_SPORE, SoundEvent.MUSIC_NETHER_CRIMSON_FOREST, List.of(
Block.NETHERRACK, Block.NETHERRACK, Block.CRIMSON_NYLIUM, Block.WARPED_NYLIUM,
Block.SOUL_SOIL, Block.GLOWSTONE, Block.NETHER_QUARTZ_ORE, Block.NETHER_GOLD_ORE
)),
new Stage(540, "Festung & Bastion", NIGHT, Particle.WHITE_ASH, SoundEvent.MUSIC_NETHER_BASALT_DELTAS, List.of(
Block.NETHER_BRICKS, Block.RED_NETHER_BRICKS, Block.BLACKSTONE,
Block.POLISHED_BLACKSTONE_BRICKS, Block.GILDED_BLACKSTONE, Block.BASALT
)),
new Stage(660, "Stronghold", NIGHT, Particle.ENCHANT, SoundEvent.MUSIC_DISC_MELLOHI, List.of(
Block.STONE_BRICKS, Block.MOSSY_STONE_BRICKS, Block.CRACKED_STONE_BRICKS,
Block.CHISELED_STONE_BRICKS, Block.BOOKSHELF
)),
new Stage(800, "Portalraum", NIGHT, Particle.REVERSE_PORTAL, SoundEvent.MUSIC_DRAGON, List.of(
Block.END_PORTAL_FRAME, Block.END_PORTAL_FRAME.withProperty("eye", "true"),
Block.STONE_BRICKS, Block.MOSSY_STONE_BRICKS
)),
new Stage(850, "End", NIGHT, Particle.END_ROD, SoundEvent.MUSIC_END, List.of(
Block.END_STONE, Block.END_STONE, Block.END_STONE_BRICKS,
Block.PURPUR_BLOCK, Block.PURPUR_PILLAR, Block.OBSIDIAN
))
);
// ab hier endlos: alle ENDLESS_SEGMENT Sprünge ein zufälliges Thema aus den Abschnitten oben
private static final int ENDLESS_FROM = 1000;
private static final int ENDLESS_SEGMENT = 50;
private static final Map<Integer, Stage> endlessStages = new ConcurrentHashMap<>();
public static Stage stageAt(int index) {
if(index >= ENDLESS_FROM) return endlessStage((index - ENDLESS_FROM) / ENDLESS_SEGMENT);
for(Stage stage : STAGES.reversed()) {
if(index >= stage.from()) return stage;
}
return STAGES.getFirst();
}
public static boolean isEndless(Stage stage) {
return stage.from() >= ENDLESS_FROM;
}
/**
* Thema eines endlosen Abschnitts. Fest an die Abschnittsnummer gebunden (gleiches Thema beim
* Weiterspielen) und nie zweimal dasselbe hintereinander. Zwischengespeichert, damit jeder
* Abschnitt genau ein Stage-Objekt hat (Abschnittswechsel werden per Identität erkannt).
*/
private static Stage endlessStage(int segment) {
return endlessStages.computeIfAbsent(segment, n -> {
Stage theme = themeOf(n);
return new Stage(ENDLESS_FROM + n * ENDLESS_SEGMENT, "Unendlich · " + theme.name(), theme.time(), theme.ambient(), theme.music(), theme.blocks());
});
}
/** Würfelt die Themen ab Abschnitt 0 der Reihe nach, damit „nie zweimal hintereinander“ sicher gilt. */
private static Stage themeOf(int segment) {
int previous = -1;
int pick = 0;
for(int n = 0; n <= segment; n++) {
pick = new Random(n * 31L + 7).nextInt(STAGES.size());
if(pick == previous) pick = (pick + 1) % STAGES.size();
previous = pick;
}
return STAGES.get(pick);
}
public static Block getRandom(int index, Random random) {
List<Block> blocks = stageAt(index).blocks();
return blocks.get(random.nextInt(blocks.size()));
}
}
@@ -0,0 +1,61 @@
package eu.mhsl.minecraft.endlessjumper.generator;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Entity;
import net.minestom.server.entity.EntityType;
import net.minestom.server.entity.metadata.display.BlockDisplayMeta;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.block.Block;
import net.minestom.server.timer.TaskSchedule;
/**
* Lässt einen neuen Block als Block-Display klein oben aus dem vorherigen Block herauswachsen
* und zu seiner Zielposition gleiten. Erst bei Ankunft wird der echte Block gesetzt.
*/
public class BlockTransition {
public static final int DURATION_TICKS = 8;
/** Zeit vom Start bis der echte Block steht (Spawn-Vorlauf + Animation + Puffer). */
public static final int TOTAL_TICKS = DURATION_TICKS + 3;
private static final double START_SCALE = 0.3;
// minimal kleiner als der Block: sobald der echte Block steht, verdeckt er das Display ohne Z-Fighting
private static final double END_SCALE = 0.998;
// der Client braucht nach dem Setzen ein, zwei Frames bis der Block sichtbar ist: so lange bleibt das Display
private static final int OVERLAP_TICKS = 4;
public static void animate(Instance world, Pos from, Pos to, Block block, int delayTicks, Runnable onArrival) {
world.scheduler().buildTask(() -> spawn(world, from, to, block, onArrival))
.delay(TaskSchedule.tick(Math.max(1, delayTicks)))
.schedule();
}
private static void spawn(Instance world, Pos from, Pos to, Block block, Runnable onArrival) {
Entity display = new Entity(EntityType.BLOCK_DISPLAY);
display.setNoGravity(true);
display.editEntityMeta(BlockDisplayMeta.class, meta -> {
meta.setBlockState(block);
// feste Helligkeit: das Display startet im vorherigen Block, dort ist Lichtstufe 0 (sonst schwarz)
meta.setBrightness(15, 15);
meta.setPosRotInterpolationDuration(DURATION_TICKS);
meta.setTransformationInterpolationDuration(DURATION_TICKS);
// klein und mittig auf der Oberseite des vorherigen Blocks starten
meta.setScale(new Vec(START_SCALE));
meta.setTranslation(new Vec((1 - START_SCALE) / 2, 1, (1 - START_SCALE) / 2));
});
display.setInstance(world, from).thenRun(() -> {
// kurz warten, damit der Client das Display kennt, bevor die Interpolation startet
display.scheduler().buildTask(() -> {
display.editEntityMeta(BlockDisplayMeta.class, meta -> {
meta.setTransformationInterpolationStartDelta(0);
meta.setScale(new Vec(END_SCALE));
meta.setTranslation(new Vec((1 - END_SCALE) / 2));
});
display.teleport(to);
}).delay(TaskSchedule.tick(2)).schedule();
display.scheduler().buildTask(onArrival).delay(TaskSchedule.tick(TOTAL_TICKS)).schedule();
display.scheduler().buildTask(display::remove).delay(TaskSchedule.tick(TOTAL_TICKS + OVERLAP_TICKS)).schedule();
});
}
}
@@ -0,0 +1,50 @@
package eu.mhsl.minecraft.endlessjumper.generator;
import net.kyori.adventure.util.RGBLike;
import net.minestom.server.color.Color;
import net.minestom.server.component.DataComponents;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Entity;
import net.minestom.server.entity.EntityType;
import net.minestom.server.entity.metadata.projectile.FireworkRocketMeta;
import net.minestom.server.instance.Instance;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import net.minestom.server.item.component.FireworkExplosion;
import net.minestom.server.item.component.FireworkList;
import net.minestom.server.timer.TaskSchedule;
import java.util.List;
import java.util.Random;
/** Steigt eine Feuerwerksrakete auf und explodiert in zufälliger Form und Farbe. */
class Fireworks {
private static final List<RGBLike> COLORS = List.of(
new Color(255, 85, 85), new Color(255, 170, 0), new Color(255, 255, 85),
new Color(85, 255, 85), new Color(85, 255, 255), new Color(85, 85, 255), new Color(255, 85, 255)
);
private static final int FLIGHT_TICKS = 20;
static void launch(Instance world, Pos at, Random random) {
FireworkExplosion.Shape[] shapes = FireworkExplosion.Shape.values();
FireworkExplosion explosion = new FireworkExplosion(
shapes[random.nextInt(shapes.length)],
List.of(COLORS.get(random.nextInt(COLORS.size())), COLORS.get(random.nextInt(COLORS.size()))),
List.of(COLORS.get(random.nextInt(COLORS.size()))),
random.nextBoolean(),
random.nextBoolean()
);
ItemStack rocketItem = ItemStack.of(Material.FIREWORK_ROCKET).with(DataComponents.FIREWORKS, new FireworkList(1, List.of(explosion)));
Entity rocket = new Entity(EntityType.FIREWORK_ROCKET);
rocket.setNoGravity(true);
rocket.editEntityMeta(FireworkRocketMeta.class, meta -> meta.setFireworkInfo(rocketItem));
rocket.setVelocity(new Vec(0, 15, 0));
rocket.setInstance(world, at).thenRun(() -> rocket.scheduler().buildTask(() -> {
// Status 17: der Client zeigt die Explosion
rocket.triggerStatus((byte) 17);
rocket.remove();
}).delay(TaskSchedule.tick(FLIGHT_TICKS)).schedule());
}
}
@@ -1,105 +1,408 @@
package eu.mhsl.minecraft.endlessjumper.generator;
import eu.mhsl.minecraft.endlessjumper.Jokers;
import eu.mhsl.minecraft.endlessjumper.Progress;
import eu.mhsl.minecraft.endlessjumper.ScoreTab;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.title.Title;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.ItemEntity;
import net.minestom.server.entity.Player;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.instance.Chunk;
import net.minestom.server.instance.Instance;
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.potion.Potion;
import net.minestom.server.potion.PotionEffect;
import net.minestom.server.sound.SoundEvent;
import net.minestom.server.timer.TaskSchedule;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/**
* Eine Partie Jump'n'Run. Die Strecke ist eine Liste von Schritten; {@link #standing} ist der Index
* des zuletzt erreichten Blocks und zugleich der Score. Vorgeneriert werden {@link #PREVIEW} Blöcke
* über den nächsten Landeblock hinaus.
*/
public class JumpGenerator {
private record Step(Pos pos, Block block) {
}
private record BlockKey(int x, int y, int z) {
static BlockKey of(Point point) {
return new BlockKey(point.blockX(), point.blockY(), point.blockZ());
}
}
private static final int PREVIEW = 3;
private static final int FULL_DIFFICULTY_AT = 1000;
private static final int FINALE_AT = 1000;
private static final int FIREWORKS_NEAR = 6, FIREWORKS_FAR = 14, FIREWORKS_SPAN_TICKS = 100;
private static final Block BRIDGE_BLOCK = Block.WHITE_STAINED_GLASS;
private static final int BRIDGE_STEP_TICKS = 4;
private static final int BRIDGE_LENGTH = 10;
private static final Block RESCUE_BLOCK = Block.OAK_PLANKS;
private static final int RESCUE_DEPTH = 6;
private static final int FEATHER_FALL_TICKS = 15 * 20;
private static final int SPRING_JUMPS = 10;
private static final double CHECKPOINT_JOKER_CHANCE = 0.1;
private static final TextColor COMBO_COLOR = TextColor.color(255, 69, 0);
private static final Title CHECKPOINT_TITLE = Title.title(Component.empty(), Component.text("Checkpoint", NamedTextColor.GREEN));
private static final Title FINALE_TITLE = Title.title(Component.text("Geschafft!", NamedTextColor.GOLD), Component.text("Durchgespielt – ab jetzt geht es endlos weiter", NamedTextColor.YELLOW));
private static final Sound CHECKPOINT_SOUND = Sound.sound(SoundEvent.ENTITY_PLAYER_LEVELUP, Sound.Source.BLOCK, 0.3f, 1f);
private static final Sound STAGE_SOUND = Sound.sound(SoundEvent.UI_TOAST_CHALLENGE_COMPLETE, Sound.Source.MASTER, 0.4f, 1f);
private static final Sound BRIDGE_SOUND = Sound.sound(SoundEvent.BLOCK_AMETHYST_BLOCK_CHIME, Sound.Source.MASTER, 1f, 1f);
private static final Sound RESCUE_SOUND = Sound.sound(SoundEvent.ITEM_TOTEM_USE, Sound.Source.MASTER, 0.6f, 1f);
private static final Sound JOKER_SOUND = Sound.sound(SoundEvent.ENTITY_EXPERIENCE_ORB_PICKUP, Sound.Source.MASTER, 0.8f, 1.2f);
private static final Sound RESET_SOUND = Sound.sound(SoundEvent.BLOCK_GLASS_BREAK, Sound.Source.BLOCK, 0.3f, 1f);
private final Player player;
private final InstanceContainer world;
private final ArrayList<Pos> blocks = new ArrayList<>();
private Pos checkpoint;
private Pos starting;
private final Instance world;
private final Random random = new Random();
private final StageAtmosphere atmosphere;
// Index des ersten Schritts in course (beim Weiterspielen der gespeicherte Checkpoint)
private final int base;
private final List<Step> course = new ArrayList<>();
private final Map<BlockKey, Integer> courseIndex = new HashMap<>();
private int standing;
private int combo = 0;
private Pos checkpoint;
// zuletzt per Brücke überbrückter Bereich: diese Blöcke zählen nicht zur Combo
private int bridgeStart = -1;
private int bridgeEnd = -1;
private final int preview = 2;
// Sprungfeder: so viele Sprünge noch mit Sprungkraft
private int springJumpsLeft = 0;
// laufende Rettung durch das Totem: der Block, auf den man über die Leiter zurückklettert
private Pos rescueSafe;
public JumpGenerator(Pos starting, Player player, InstanceContainer world) {
/** @param startIndex Fortschritt, ab dem gespielt wird (0 oder ein gespeicherter Checkpoint) */
public JumpGenerator(Pos start, int startIndex, Player player, Instance world) {
this.player = player;
this.world = world;
this.starting = starting;
generateStart(starting);
}
public void generateNext() {
boolean isTenth = this.blocks.size() % 10 == 0;
boolean standingTenth = (this.blocks.size()-this.preview-1) % 10 == 0;
boolean isHundredth = this.blocks.size() % 100 == 0;
float pitch = 1 + (combo * 0.025f);
this.player.playSound(Sound.sound(SoundEvent.BLOCK_NOTE_BLOCK_PLING, Sound.Source.MASTER, 0.1f, pitch));
this.player.showTitle(Title.title(Component.text(""), Component.text((combo)+"", TextColor.color(255, 69, 0))));
this.player.setLevel((this.blocks.size()-this.preview-1));
Random r = new Random();
Pos target = blocks.get(blocks.size()-1).add(r.nextInt(-1, 2), r.nextInt(this.player.getPosition().y() < 80 ? 0 : -1, this.player.getPosition().y() > 120 ? 1 : 2), r.nextInt(3, 5));
this.world.setBlock(target, isHundredth ? Block.DIAMOND_BLOCK : isTenth ? Block.GOLD_BLOCK : BlockPalette.getRandom((this.blocks.size() / 10)));
if(standingTenth) {
Pos current = this.blocks.get(this.blocks.size()-this.preview-1);
this.checkpoint = current;
this.player.playSound(Sound.sound(SoundEvent.ENTITY_PLAYER_LEVELUP, Sound.Source.BLOCK, 0.3f, 1f));
this.player.showTitle(Title.title(Component.text(""), Component.text("Checkpoint", TextColor.color(0, 255, 0))));
this.player.sendPacket(new ParticlePacket(Particle.CRIT, current, current.add(0.5), 1.5f, 50));
}
this.world.setBlock(target.add(0, 1 ,0), Block.LIGHT);
this.blocks.add(target);
this.player.sendPacket(new ParticlePacket(Particle.CRIT, target, target.add(0.5), 1, 10));
this.player.sendPacket(new ParticlePacket(Particle.FALLING_NECTAR, target, target.add(0.5),0.3f, 10));
this.combo++;
}
public Pos getNextLanding() {
return this.blocks.get(this.blocks.size()-(preview+1));
}
public void generateStart(Pos start) {
this.starting = start;
this.base = startIndex;
this.standing = startIndex;
this.checkpoint = start;
this.blocks.add(this.starting);
this.world.setBlock(this.starting, Block.DIAMOND_BLOCK);
for(int i = 0; i <= preview; i++) generateNext();
this.atmosphere = new StageAtmosphere(player, world, BlockPalette.stageAt(startIndex));
Block startBlock = startIndex == 0 || isMilestone(startIndex) ? Block.DIAMOND_BLOCK : Block.GOLD_BLOCK;
this.world.setBlock(start, startBlock);
this.addStep(new Step(start, startBlock));
this.player.setLevel(startIndex);
ScoreTab.update(this.player, startIndex);
// wer ganz von vorn beginnt, bekommt ein Totem; beim Weiterspielen gibt es keine Joker
if(startIndex > 0) this.player.sendMessage(Component.text("Weiter ab Sprung " + startIndex, NamedTextColor.GOLD));
else Jokers.TOTEM.add(this.player);
// nacheinander animieren, damit jeder Block aus seinem bereits stehenden Vorgänger wächst
this.ensureGenerated(startIndex + PREVIEW + 1, BlockTransition.TOTAL_TICKS);
}
public void reset() {
/*
for(Pos p : this.blocks) {
this.world.setBlock(p, Block.AIR);
private Step step(int index) {
return this.course.get(index - this.base);
}
/** Index des nächsten zu erzeugenden Schritts. */
private int generated() {
return this.base + this.course.size();
}
private static boolean isCheckpoint(int index) {
return index % 10 == 0;
}
private static boolean isMilestone(int index) {
return index % 100 == 0;
}
private static double height(Block block) {
return block.collisionShape().relativeEnd().y();
}
private Pos landing() {
return this.step(this.standing + 1).pos();
}
/** Wird bei jeder Bewegung aufgerufen: erkennt Landungen und behandelt das Runterfallen. */
public void onMove(Pos newPosition) {
// Block unter den Füßen; -0.5 trifft auch niedrige Blöcke wie den Endportalrahmen
Integer index = this.courseIndex.get(BlockKey.of(newPosition.sub(0, 0.5, 0)));
if(index != null && index > this.standing) this.advanceTo(index);
this.checkFall();
}
private void advanceTo(int index) {
int previous = this.standing;
this.standing = index;
this.player.setLevel(index);
ScoreTab.update(this.player, index);
if(this.springJumpsLeft > 0 && --this.springJumpsLeft == 0) this.player.removeEffect(PotionEffect.JUMP_BOOST);
if(index < this.bridgeStart || index > this.bridgeEnd) {
this.combo++;
this.player.playSound(Sound.sound(SoundEvent.BLOCK_NOTE_BLOCK_PLING, Sound.Source.MASTER, 0.1f, 1 + this.combo * 0.025f));
this.player.showTitle(Title.title(Component.empty(), Component.text(this.combo, COMBO_COLOR)));
}
this.blocks.clear();
*/
// auch ein übersprungener Checkpoint zählt: der letzte zwischen altem und neuem Stand
int reachedCheckpoint = index / 10 * 10;
if(reachedCheckpoint > previous) {
this.checkpoint = this.step(reachedCheckpoint).pos();
Progress.save(this.player.getUuid(), reachedCheckpoint);
this.unloadChunksBehind();
this.player.playSound(CHECKPOINT_SOUND);
this.player.showTitle(CHECKPOINT_TITLE);
this.player.sendPacket(new ParticlePacket(Particle.CRIT, this.checkpoint, this.checkpoint.add(0.5), 1.5f, 50));
}
// neuer Abschnitt: Stimmung wechseln (die Actionbar zeigt der Abschnitt selbst dauerhaft)
BlockPalette.Stage stage = BlockPalette.stageAt(index);
if(stage != BlockPalette.stageAt(previous)) {
this.atmosphere.enter(stage);
this.player.playSound(STAGE_SOUND);
}
if(previous < FINALE_AT && index >= FINALE_AT) this.celebrate();
this.ensureGenerated(index + PREVIEW + 1, 0);
}
/** Durchgespielt (Beginn von „Unendlich“): Feuerwerk rund um den Spieler. */
private void celebrate() {
this.player.showTitle(FINALE_TITLE);
// in Blickrichtung: ein paar Raketen nah vor dem Spieler, die meisten in der Ferne links, mittig und rechts
Pos origin = this.player.getPosition();
Vec forward = origin.direction().withY(0).normalize();
Vec right = new Vec(-forward.z(), 0, forward.x());
for(int i = 0; i < FIREWORKS_NEAR + FIREWORKS_FAR; i++) {
boolean near = i < FIREWORKS_NEAR;
double distance = near ? this.random.nextDouble(3, 10) : this.random.nextDouble(20, 45);
double side = near ? this.random.nextDouble(-6, 6) : this.random.nextDouble(-25, 25);
Pos at = origin.add(forward.mul(distance)).add(right.mul(side)).add(0, near ? 0 : 4, 0);
this.world.scheduler().buildTask(() -> Fireworks.launch(this.world, at, this.random))
.delay(TaskSchedule.tick(1 + this.random.nextInt(FIREWORKS_SPAN_TICKS)))
.schedule();
}
}
/**
* Die Strecke wandert immer in +z. Chunks hinter dem Checkpoint und außerhalb der Sichtweite
* werden nie wieder gebraucht (Reset und Rettung liegen davor): freigeben, sonst sammeln sich
* bei langen Läufen tausende an.
*/
private void unloadChunksBehind() {
int playerChunk = this.player.getPosition().blockZ() >> 4;
int checkpointChunk = this.checkpoint.blockZ() >> 4;
int keepFrom = Math.min(checkpointChunk, playerChunk - this.player.effectiveViewDistance()) - 1;
for(Chunk chunk : List.copyOf(this.world.getChunks())) {
if(chunk.getChunkZ() < keepFrom) this.world.unloadChunk(chunk);
}
}
private void ensureGenerated(int upTo, int stepTicks) {
int delay = 0;
while(this.generated() <= upTo) {
this.generateStep(delay, null);
delay += stepTicks;
}
}
private void addStep(Step step) {
this.courseIndex.put(BlockKey.of(step.pos()), this.generated());
this.course.add(step);
}
/** Erzeugt den nächsten Streckenblock; {@code afterArrival} läuft, sobald er tatsächlich steht. */
private void generateStep(int animationDelayTicks, Runnable afterArrival) {
int index = this.generated();
Step previous = this.course.getLast();
Block block = isMilestone(index) ? Block.DIAMOND_BLOCK : isCheckpoint(index) ? Block.GOLD_BLOCK : BlockPalette.getRandom(index, this.random);
Pos target = previous.pos().add(this.nextOffset(index, previous.block(), block));
this.addStep(new Step(target, block));
BlockTransition.animate(this.world, previous.pos(), target, block, animationDelayTicks, () -> {
this.world.setBlock(target, block);
this.world.setBlock(target.add(0, 1, 0), Block.LIGHT);
this.player.sendPacket(new ParticlePacket(Particle.CRIT, target, target.add(0.5), 1, 10));
this.player.sendPacket(new ParticlePacket(Particle.FALLING_NECTAR, target, target.add(0.5), 0.3f, 10));
if(isMilestone(index) || (isCheckpoint(index) && this.random.nextDouble() < CHECKPOINT_JOKER_CHANCE)) this.dropJoker(target);
if(afterArrival != null) afterArrival.run();
});
}
/** Auf jedem Diamantblock (und manchem Checkpoint) liegt ein zufälliger Joker zum Einsammeln. */
private void dropJoker(Pos target) {
ItemEntity item = new ItemEntity(Jokers.random(this.random).item());
item.setPickupDelay(Duration.ZERO);
item.setInstance(this.world, target.add(0.5, 1, 0.5));
}
/**
* Abstand zum nächsten Block. Sprünge sind immer mindestens 3 weit (natürliche Sprint-Weite,
* kürzere werden überjumpt). Über die ersten {@link #FULL_DIFFICULTY_AT} Sprünge steigen der
* Anteil an 4er-Sprüngen, seitlichen Versätzen und Sprüngen nach oben bis zum bisherigen
* Niveau und bleiben danach dort.
*/
private Vec nextOffset(int index, Block from, Block to) {
double difficulty = Math.min(1.0, (double) index / FULL_DIFFICULTY_AT);
int dz = this.random.nextDouble() < 0.1 + 0.4 * difficulty ? 4 : 3;
int dx = this.random.nextDouble() < 0.1 + 0.57 * difficulty ? (this.random.nextBoolean() ? 1 : -1) : 0;
double y = this.player.getPosition().y();
double roll = this.random.nextDouble();
int dy = 0;
if(roll < 0.05 + 0.28 * difficulty && y <= 120) dy = 1;
else if(roll > 1 - (0.2 + 0.13 * difficulty) && y >= 80) dy = -1;
// von niedrigen Blöcken (z. B. Endportalrahmen) nie nach oben, und die Oberkante darf
// höchstens einen Block höher liegen
if(dy == 1 && (height(from) < 1 || height(to) > height(from))) dy = 0;
// weite Sprünge nach oben erst ab mittlerer, diagonal erst ab hoher Schwierigkeit
if(dy == 1 && dz == 4 && difficulty < 0.4) dz = 3;
if(dy == 1 && dz == 4 && dx != 0 && difficulty < 0.7) dx = 0;
return new Vec(dx, dy, dz);
}
/** Rechtsklick mit einem Joker; true, wenn er verbraucht wurde. Das Totem wirkt nur automatisch. */
public boolean useJoker(Jokers joker) {
boolean used = switch(joker) {
case BRIDGE -> this.useBridge();
case FEATHER_FALL -> {
this.player.addEffect(new Potion(PotionEffect.SLOW_FALLING, 0, FEATHER_FALL_TICKS));
yield true;
}
case SPRING -> {
this.springJumpsLeft = SPRING_JUMPS;
this.player.addEffect(new Potion(PotionEffect.JUMP_BOOST, 0, Potion.INFINITE_DURATION));
yield true;
}
case TOTEM -> false;
};
if(used) this.player.playSound(JOKER_SOUND);
return used;
}
/**
* Joker „Brücke“: verbindet die nächsten {@link #BRIDGE_LENGTH} Blöcke ab der aktuellen Position
* mit einem begehbaren Weg, auch über früher schon geschaffte Stücke (z. B. nach einem Reset).
* Diese Blöcke zählen nicht zur Combo. Ist der Bereich schon überbrückt, passiert nichts.
*/
private boolean useBridge() {
int target = this.standing + BRIDGE_LENGTH;
if(this.rescueSafe != null || (this.standing >= this.bridgeStart && target <= this.bridgeEnd)) return false;
this.bridgeStart = this.standing;
this.bridgeEnd = target;
// bereits stehende Abschnitte sofort, neue Blöcke bekommen ihren Weg bei Ankunft
for(int i = this.standing + 1; i < this.generated() && i <= target; i++) {
this.buildPath(this.step(i - 1).pos(), this.step(i).pos());
}
int delay = 0;
while(this.generated() <= target) {
Pos from = this.course.getLast().pos();
int index = this.generated();
this.generateStep(delay, () -> this.buildPath(from, this.step(index).pos()));
delay += BRIDGE_STEP_TICKS;
}
this.player.playSound(BRIDGE_SOUND);
return true;
}
/** Weg auf Höhe des Startblocks; bei seitlichem Versatz zwei Blöcke breit, damit er durchgehend ist. */
private void buildPath(Pos from, Pos to) {
int minX = Math.min(from.blockX(), to.blockX());
int maxX = Math.max(from.blockX(), to.blockX());
for(int z = from.blockZ() + 1; z < to.blockZ(); z++) {
for(int x = minX; x <= maxX; x++) {
Pos pos = new Pos(x, from.blockY(), z);
if(this.world.getBlock(pos).compare(Block.AIR)) this.world.setBlock(pos, BRIDGE_BLOCK);
}
}
}
private void checkFall() {
double y = this.player.getPosition().y();
if(this.rescueSafe != null) {
if(y >= this.rescueSafe.y() + 0.9) this.rescueSafe = null;
else if(y < this.rescueSafe.blockY() - RESCUE_DEPTH - 3) {
this.rescueSafe = null;
this.reset();
}
return;
}
if(y < this.landing().y() - 5 && y < this.checkpoint.y()) {
if(Jokers.TOTEM.take(this.player)) this.rescue();
else this.reset();
}
}
/**
* Joker „Rettungstotem“: Plattform unter dem zuletzt erreichten Block, davor eine Stützsäule
* mit Leiter, über die man direkt wieder auf den Block klettert. Die Strecke geht immer
* mindestens 3 Blöcke nach vorne, die Rettung baut nur dahinter und darunter.
*/
private void rescue() {
Pos safe = this.step(this.standing).pos();
int x = safe.blockX();
int z = safe.blockZ();
int floorY = safe.blockY() - RESCUE_DEPTH;
this.rescueSafe = safe;
for(int dx = -1; dx <= 1; dx++) {
for(int dz = -3; dz <= -1; dz++) this.placeRescue(new Pos(x + dx, floorY, z + dz), RESCUE_BLOCK);
}
// Säule direkt hinter dem Block bis auf seine Höhe, Leiter davor (hängt an der Säule)
for(int y = floorY + 1; y <= safe.blockY(); y++) {
this.placeRescue(new Pos(x, y, z - 1), RESCUE_BLOCK);
this.placeRescue(new Pos(x, y, z - 2), Block.LADDER.withProperty("facing", "north"));
}
this.player.setVelocity(Vec.ZERO);
this.player.teleport(new Pos(x + 0.5, floorY + 1, z - 1.5, 0, 0));
this.player.triggerStatus((byte) 35);
this.player.playSound(RESCUE_SOUND);
}
/** Brückenglas darf überschrieben werden, sonst könnte ein früherer Brückenweg die Leiter versperren. */
private void placeRescue(Pos pos, Block block) {
Block existing = this.world.getBlock(pos);
if(existing.compare(Block.AIR) || existing.compare(BRIDGE_BLOCK)) this.world.setBlock(pos, block);
}
/** Beendet alles, was an den Spieler gebunden weiterläuft (Partikel, Musik, Joker-Effekte). */
public void close() {
this.atmosphere.stop();
this.player.removeEffect(PotionEffect.SLOW_FALLING);
this.player.removeEffect(PotionEffect.JUMP_BOOST);
}
private void reset() {
this.player.teleport(this.checkpoint.add(0.5, 1, 0.5));
this.combo = 0;
this.player.showTitle(Title.title(Component.text(""), Component.text("")));
//generateStart(last);
this.player.playSound(Sound.sound(SoundEvent.BLOCK_GLASS_BREAK, Sound.Source.BLOCK, 0.3f, 1f));
}
public Pos getCheckpoint() {
return checkpoint;
}
public ArrayList<Pos> getBlocks() {
return blocks;
}
public int getPreview() {
return preview;
this.player.clearTitle();
this.player.playSound(RESET_SOUND);
}
}
@@ -0,0 +1,103 @@
package eu.mhsl.minecraft.endlessjumper.generator;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.sound.SoundStop;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Player;
import net.minestom.server.instance.Instance;
import net.minestom.server.network.packet.server.play.ParticlePacket;
import net.minestom.server.timer.Task;
import net.minestom.server.timer.TaskSchedule;
import java.util.function.Supplier;
/**
* Stimmung des aktuellen Abschnitts: Tageszeit (fließender Übergang), Musik, Partikel auf der
* Strecke voraus und der Abschnittsname dauerhaft in der Actionbar.
*/
class StageAtmosphere {
private static final int TIME_TRANSITION_TICKS = 100;
// die Strecke läuft immer in +z: Partikel vom Spieler bis etwa 10 Sprünge voraus
private static final Vec AMBIENT_AHEAD = new Vec(0, 1.5, 14);
private static final Vec AMBIENT_SPREAD = new Vec(7, 4, 16);
private static final int AMBIENT_EVERY_TICKS = 4;
// die Actionbar blendet nach ~3 s aus: jede Sekunde erneuern, nach einem Wechsel 5 s golden
private static final int ACTION_BAR_TICKS = 20;
private static final int HIGHLIGHT_TICKS = 100;
private static final Component ENDLESS_HINT = Component.text(" · /reset für einen Neustart", NamedTextColor.DARK_GRAY);
private final Player player;
private final Instance world;
private BlockPalette.Stage stage;
private Task timeTransition;
private final Task ambient;
private int ticks = 0;
private int highlightUntil = 0;
StageAtmosphere(Player player, Instance world, BlockPalette.Stage initial) {
this.player = player;
this.world = world;
this.stage = initial;
this.world.setTime(initial.time());
this.world.refreshTime();
this.playMusic();
// an den Spieler gebunden, endet also mit ihm
this.ambient = player.scheduler().buildTask(this::tick).repeat(TaskSchedule.tick(AMBIENT_EVERY_TICKS)).schedule();
}
void stop() {
this.ambient.cancel();
if(this.timeTransition != null) this.timeTransition.cancel();
this.player.stopSound(SoundStop.source(Sound.Source.MUSIC));
}
void enter(BlockPalette.Stage next) {
if(next == this.stage) return;
this.stage = next;
this.highlightUntil = this.ticks + HIGHLIGHT_TICKS;
this.playMusic();
this.fadeTimeTo(next.time());
this.showStage();
}
private void playMusic() {
this.player.stopSound(SoundStop.source(Sound.Source.MUSIC));
this.player.playSound(Sound.sound(this.stage.music(), Sound.Source.MUSIC, 1f, 1f));
}
/** Läuft immer vorwärts zur Zielzeit, Tick für Tick, damit Himmel und Licht weich wechseln. */
private void fadeTimeTo(long target) {
if(this.timeTransition != null) this.timeTransition.cancel();
long start = this.world.getTime();
long delta = Math.floorMod(target - start, 24000L);
if(delta == 0) return;
this.timeTransition = this.world.scheduler().submitTask(new Supplier<>() {
private int tick = 0;
@Override
public TaskSchedule get() {
this.tick++;
StageAtmosphere.this.world.setTime(start + delta * this.tick / TIME_TRANSITION_TICKS);
StageAtmosphere.this.world.refreshTime();
return this.tick < TIME_TRANSITION_TICKS ? TaskSchedule.tick(1) : TaskSchedule.stop();
}
});
}
private void tick() {
this.ticks += AMBIENT_EVERY_TICKS;
if(this.ticks % ACTION_BAR_TICKS == 0) this.showStage();
if(this.stage.ambient() != null) {
this.player.sendPacket(new ParticlePacket(this.stage.ambient(), this.player.getPosition().add(AMBIENT_AHEAD), AMBIENT_SPREAD, 0.02f, 8));
}
}
private void showStage() {
Component text = Component.text(this.stage.name(), this.ticks < this.highlightUntil ? NamedTextColor.GOLD : NamedTextColor.GRAY);
if(BlockPalette.isEndless(this.stage)) text = text.append(ENDLESS_HINT);
this.player.sendActionBar(text);
}
}
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
#Generated by Maven
#Fri Dec 19 20:45:21 CET 2025
artifactId=EndlessJumper
groupId=org.example
version=1.0-SNAPSHOT
@@ -1,5 +0,0 @@
eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.class
eu/mhsl/minecraft/endlessjumper/TerrainGenerator.class
eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.class
eu/mhsl/minecraft/endlessjumper/BlockMath.class
eu/mhsl/minecraft/endlessjumper/EndlessJumper.class
@@ -1,5 +0,0 @@
/home/elias/projects/Minecraft/EndlessJumper/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java
/home/elias/projects/Minecraft/EndlessJumper/src/main/java/eu/mhsl/minecraft/endlessjumper/BlockMath.java
/home/elias/projects/Minecraft/EndlessJumper/src/main/java/eu/mhsl/minecraft/endlessjumper/TerrainGenerator.java
/home/elias/projects/Minecraft/EndlessJumper/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java
/home/elias/projects/Minecraft/EndlessJumper/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java
Binary file not shown.