Compare commits

...
23 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
14 changed files with 945 additions and 182 deletions

No files matched your search

+3
View File
@@ -4,3 +4,6 @@ target/
*.iml *.iml
.idea/workspace.xml .idea/workspace.xml
forwarding.secret forwarding.secret
progress.properties
progress.properties.tmp
admins.txt
+10 -2
View File
@@ -29,12 +29,20 @@ shadowJar {
mergeServiceFiles() mergeServiceFiles()
} }
// Hochladen auf den Limbo: ./gradlew uploadLimbo, danach `sudo systemctl restart limbo` // 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') { tasks.register('uploadLimbo') {
dependsOn shadowJar dependsOn shadowJar
doLast { doLast {
def jar = shadowJar.archiveFile.get().asFile
def checksum = jar.bytes.digest('SHA-256')
providers.exec { providers.exec {
commandLine 'scp', shadowJar.archiveFile.get().asFile.absolutePath, 'minecraft@10.20.9.3:/home/minecraft/limbo/limbo.jar' 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() }.result.get()
} }
} }
@@ -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());
}
}
@@ -3,89 +3,141 @@ package eu.mhsl.minecraft.endlessjumper;
import eu.mhsl.minecraft.endlessjumper.generator.JumpGenerator; import eu.mhsl.minecraft.endlessjumper.generator.JumpGenerator;
import net.minestom.server.Auth; import net.minestom.server.Auth;
import net.minestom.server.MinecraftServer; 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.coordinate.Pos;
import net.minestom.server.entity.Player; import net.minestom.server.entity.Player;
import net.minestom.server.entity.PlayerSkin; import net.minestom.server.entity.PlayerSkin;
import net.minestom.server.event.GlobalEventHandler; 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.AsyncPlayerConfigurationEvent;
import net.minestom.server.event.player.PlayerBlockBreakEvent; 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.PlayerDisconnectEvent;
import net.minestom.server.event.player.PlayerMoveEvent; import net.minestom.server.event.player.PlayerMoveEvent;
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.InstanceContainer;
import net.minestom.server.instance.InstanceManager;
import net.minestom.server.instance.block.Block; import net.minestom.server.instance.block.Block;
import net.minestom.server.timer.TaskSchedule;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class EndlessJumper { public class EndlessJumper {
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 { public static void main(String[] args) throws IOException {
MinecraftServer server = MinecraftServer.init(new Auth.Velocity(readForwardingSecret())); MinecraftServer server = MinecraftServer.init(new Auth.Velocity(readForwardingSecret()));
InstanceManager instanceManager = MinecraftServer.getInstanceManager(); Progress.load();
Admins.load();
GlobalEventHandler eventHandler = MinecraftServer.getGlobalEventHandler(); GlobalEventHandler eventHandler = MinecraftServer.getGlobalEventHandler();
PlayerSkin skin = PlayerSkin.fromUsername("Notch"); PlayerSkin skin = PlayerSkin.fromUsername("Notch");
HashMap<Player, JumpGenerator> games = new HashMap<>();
eventHandler.addListener(AsyncPlayerConfigurationEvent.class, event -> { eventHandler.addListener(AsyncPlayerConfigurationEvent.class, event -> {
final Player p = event.getPlayer(); final Player p = event.getPlayer();
p.setRespawnPoint(new Pos(0.5,101.5,0.5)); p.setRespawnPoint(SPAWN);
final InstanceContainer world = instanceManager.createInstanceContainer();
world.setGenerator(new TerrainGenerator());
Pos startingAt = new Pos(0,100,0);
world.setBlock(startingAt, Block.DIAMOND_BLOCK);
world.defaultClock().pause();
world.setTime(18000);
p.setSkin(skin); p.setSkin(skin);
event.setSpawningInstance(createWorld());
});
event.setSpawningInstance(world); eventHandler.addListener(PlayerSpawnEvent.class, event -> {
if(!event.isFirstSpawn()) return;
MinecraftServer.getSchedulerManager().scheduleTask( Player p = event.getPlayer();
() -> games.put(p, new JumpGenerator(startingAt, p, world)), p.setCanPickupItem(true);
TaskSchedule.seconds(3), ScoreTab.join(p);
TaskSchedule.stop() 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 -> { eventHandler.addListener(PlayerMoveEvent.class, event -> {
Player p = event.getPlayer(); JumpGenerator generator = games.get(event.getPlayer());
if(generator == null) event.setCancelled(true);
if(!games.containsKey(p)) { else generator.onMove(event.getNewPosition());
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();
}); });
eventHandler.addListener(PlayerBlockBreakEvent.class, event -> event.setCancelled(true)); 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);
server.start("0.0.0.0", 25501); 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 // Wie bei Velocity: Secret aus forwarding.secret im Arbeitsverzeichnis, alternativ per Umgebungsvariable
@@ -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,77 +1,111 @@
package eu.mhsl.minecraft.endlessjumper.generator; package eu.mhsl.minecraft.endlessjumper.generator;
import net.minestom.server.instance.block.Block; import net.minestom.server.instance.block.Block;
import net.minestom.server.particle.Particle;
import net.minestom.server.sound.SoundEvent;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Random; import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Block-Themen entlang des Fortschritts: einmal durch das komplette Spiel, * Abschnitte entlang des Fortschritts: einmal durch das komplette Spiel, von der Wiese über
* von der Wiese über Untergrund, Nether und Stronghold bis ins End (ab Sprung 1000). * 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 class BlockPalette {
public record Stage(int from, String name, List<Block> blocks) { /** @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) {
} }
public static final List<Stage> STAGES = List.of( private static final long MORNING = 1000, NOON = 6000, NIGHT = 18000;
new Stage(0, "Wiese", List.of(
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 Block.GRASS_BLOCK, Block.GRASS_BLOCK, Block.MOSS_BLOCK, Block.PODZOL, Block.ROOTED_DIRT
)), )),
new Stage(75, "Wald", List.of( 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_LOG, Block.BIRCH_LOG, Block.SPRUCE_LOG, Block.CHERRY_LOG,
Block.OAK_LEAVES, Block.BIRCH_LEAVES, Block.SPRUCE_LEAVES, Block.CHERRY_LEAVES Block.OAK_LEAVES, Block.BIRCH_LEAVES, Block.SPRUCE_LEAVES, Block.CHERRY_LEAVES
)), )),
new Stage(150, "Untergrund", List.of( 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.DIRT, Block.COARSE_DIRT, Block.STONE, Block.STONE, Block.ANDESITE,
Block.GRANITE, Block.DIORITE, Block.COBBLESTONE Block.GRANITE, Block.DIORITE, Block.COBBLESTONE
)), )),
new Stage(250, "Höhlen", List.of( 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.STONE, Block.COAL_ORE, Block.IRON_ORE, Block.COPPER_ORE,
Block.GOLD_ORE, Block.REDSTONE_ORE, Block.LAPIS_ORE Block.GOLD_ORE, Block.REDSTONE_ORE, Block.LAPIS_ORE
)), )),
new Stage(350, "Tiefenschiefer", List.of( 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, Block.DEEPSLATE, Block.TUFF, Block.DEEPSLATE_DIAMOND_ORE,
Block.DEEPSLATE_IRON_ORE, Block.AMETHYST_BLOCK Block.DEEPSLATE_IRON_ORE, Block.AMETHYST_BLOCK
)), )),
new Stage(450, "Netherportal", List.of( new Stage(390, "Netherportal", NIGHT, Particle.PORTAL, SoundEvent.MUSIC_NETHER_NETHER_WASTES, List.of(
Block.OBSIDIAN, Block.OBSIDIAN, Block.CRYING_OBSIDIAN Block.OBSIDIAN, Block.OBSIDIAN, Block.CRYING_OBSIDIAN
)), )),
new Stage(475, "Nether", List.of( 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.NETHERRACK, Block.NETHERRACK, Block.CRIMSON_NYLIUM, Block.WARPED_NYLIUM,
Block.SOUL_SOIL, Block.GLOWSTONE, Block.NETHER_QUARTZ_ORE, Block.NETHER_GOLD_ORE Block.SOUL_SOIL, Block.GLOWSTONE, Block.NETHER_QUARTZ_ORE, Block.NETHER_GOLD_ORE
)), )),
new Stage(625, "Festung & Bastion", List.of( 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.NETHER_BRICKS, Block.RED_NETHER_BRICKS, Block.BLACKSTONE,
Block.POLISHED_BLACKSTONE_BRICKS, Block.GILDED_BLACKSTONE, Block.BASALT Block.POLISHED_BLACKSTONE_BRICKS, Block.GILDED_BLACKSTONE, Block.BASALT
)), )),
new Stage(750, "Stronghold", List.of( 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.STONE_BRICKS, Block.MOSSY_STONE_BRICKS, Block.CRACKED_STONE_BRICKS,
Block.CHISELED_STONE_BRICKS, Block.BOOKSHELF Block.CHISELED_STONE_BRICKS, Block.BOOKSHELF
)), )),
new Stage(925, "Portalraum", List.of( 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.END_PORTAL_FRAME, Block.END_PORTAL_FRAME.withProperty("eye", "true"),
Block.STONE_BRICKS, Block.MOSSY_STONE_BRICKS Block.STONE_BRICKS, Block.MOSSY_STONE_BRICKS
)), )),
new Stage(1000, "End", List.of( new Stage(850, "End", NIGHT, Particle.END_ROD, SoundEvent.MUSIC_END, List.of(
Block.END_STONE, Block.END_STONE, Block.END_STONE_BRICKS, Block.END_STONE, Block.END_STONE, Block.END_STONE_BRICKS,
Block.PURPUR_BLOCK, Block.PURPUR_PILLAR, Block.OBSIDIAN 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) { public static Stage stageAt(int index) {
Stage current = STAGES.getFirst(); if(index >= ENDLESS_FROM) return endlessStage((index - ENDLESS_FROM) / ENDLESS_SEGMENT);
for(Stage stage : STAGES) { for(Stage stage : STAGES.reversed()) {
if(index >= stage.from()) current = stage; if(index >= stage.from()) return stage;
} }
return current; return STAGES.getFirst();
} }
/** Liefert die Stufe, die genau bei diesem Index beginnt, sonst null. */ public static boolean isEndless(Stage stage) {
public static Stage stageStartingAt(int index) { return stage.from() >= ENDLESS_FROM;
for(Stage stage : STAGES) {
if(stage.from() == index) return stage;
} }
return null;
/**
* 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) { public static Block getRandom(int index, Random random) {
@@ -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,140 +1,408 @@
package eu.mhsl.minecraft.endlessjumper.generator; 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.sound.Sound;
import net.kyori.adventure.text.Component; 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.TextColor;
import net.kyori.adventure.title.Title; import net.kyori.adventure.title.Title;
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos; 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.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.instance.block.Block;
import net.minestom.server.network.packet.server.play.ParticlePacket; import net.minestom.server.network.packet.server.play.ParticlePacket;
import net.minestom.server.particle.Particle; 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.sound.SoundEvent;
import net.minestom.server.timer.TaskSchedule;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random; 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 { public class JumpGenerator {
private final Player player; private record Step(Pos pos, Block block) {
private final InstanceContainer world; }
private final ArrayList<Pos> blocks = new ArrayList<>();
private Pos checkpoint;
private Pos starting;
private int combo = 0; private record BlockKey(int x, int y, int z) {
static BlockKey of(Point point) {
private final int preview = 2; 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 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;
public JumpGenerator(Pos starting, Player player, InstanceContainer world) { 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 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;
// 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;
/** @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.player = player;
this.world = world; this.world = world;
this.starting = starting; this.base = startIndex;
generateStart(starting); this.standing = startIndex;
this.checkpoint = start;
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 generateNext() { private Step step(int index) {
boolean isTenth = this.blocks.size() % 10 == 0; return this.course.get(index - this.base);
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();
int index = this.blocks.size();
Pos target = blocks.get(blocks.size()-1).add(this.nextOffset(index, r));
this.world.setBlock(target, isHundredth ? Block.DIAMOND_BLOCK : isTenth ? Block.GOLD_BLOCK : BlockPalette.getRandom(index, r));
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));
} }
BlockPalette.Stage reachedStage = BlockPalette.stageStartingAt(this.blocks.size()-this.preview-1); /** Index des nächsten zu erzeugenden Schritts. */
if(reachedStage != null && reachedStage.from() > 0) { private int generated() {
this.player.playSound(Sound.sound(SoundEvent.UI_TOAST_CHALLENGE_COMPLETE, Sound.Source.MASTER, 0.4f, 1f)); return this.base + this.course.size();
this.player.showTitle(Title.title(Component.text(reachedStage.name(), TextColor.color(255, 170, 0)), Component.text("")));
} }
this.world.setBlock(target.add(0, 1 ,0), Block.LIGHT);
this.blocks.add(target); private static boolean isCheckpoint(int index) {
this.player.sendPacket(new ParticlePacket(Particle.CRIT, target, target.add(0.5), 1, 10)); return index % 10 == 0;
this.player.sendPacket(new ParticlePacket(Particle.FALLING_NECTAR, target, target.add(0.5),0.3f, 10)); }
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.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)));
}
// 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();
}
} }
/** /**
* Abstand zum nächsten Block. Die Schwierigkeit steigt über die ersten {@link #FULL_DIFFICULTY_AT} * Die Strecke wandert immer in +z. Chunks hinter dem Checkpoint und außerhalb der Sichtweite
* Sprünge von leicht (2–3 weit, kaum seitlich/aufwärts) bis zum bisherigen Niveau (3–4 weit, * werden nie wieder gebraucht (Reset und Rettung liegen davor): freigeben, sonst sammeln sich
* oft seitlich und aufwärts) und bleibt danach dort. * bei langen Läufen tausende an.
*/ */
private Pos nextOffset(int index, Random r) { 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); double difficulty = Math.min(1.0, (double) index / FULL_DIFFICULTY_AT);
int dz = 2; int dz = this.random.nextDouble() < 0.1 + 0.4 * difficulty ? 4 : 3;
if(r.nextDouble() < 0.5 + 0.5 * difficulty) dz++; int dx = this.random.nextDouble() < 0.1 + 0.57 * difficulty ? (this.random.nextBoolean() ? 1 : -1) : 0;
if(r.nextDouble() < 0.5 * difficulty) dz++;
int dx = r.nextDouble() < 0.1 + 0.57 * difficulty ? (r.nextBoolean() ? 1 : -1) : 0;
double y = this.player.getPosition().y(); double y = this.player.getPosition().y();
double roll = r.nextDouble(); double roll = this.random.nextDouble();
int dy = 0; int dy = 0;
if(roll < 0.05 + 0.28 * difficulty && y <= 120) dy = 1; if(roll < 0.05 + 0.28 * difficulty && y <= 120) dy = 1;
else if(roll > 1 - (0.2 + 0.13 * difficulty) && y >= 80) dy = -1; else if(roll > 1 - (0.2 + 0.13 * difficulty) && y >= 80) dy = -1;
// weite Diagonalsprünge nach oben erst gegen Ende // 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; if(dy == 1 && dz == 4 && dx != 0 && difficulty < 0.7) dx = 0;
return new Pos(dx, dy, dz); return new Vec(dx, dy, dz);
} }
public Pos getNextLanding() { /** Rechtsklick mit einem Joker; true, wenn er verbraucht wurde. Das Totem wirkt nur automatisch. */
return this.blocks.get(this.blocks.size()-(preview+1)); 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;
} }
/**
public void generateStart(Pos start) { * Joker „Brücke“: verbindet die nächsten {@link #BRIDGE_LENGTH} Blöcke ab der aktuellen Position
this.starting = start; * mit einem begehbaren Weg, auch über früher schon geschaffte Stücke (z. B. nach einem Reset).
this.checkpoint = start; * Diese Blöcke zählen nicht zur Combo. Ist der Bereich schon überbrückt, passiert nichts.
this.blocks.add(this.starting);
this.world.setBlock(this.starting, Block.DIAMOND_BLOCK);
for(int i = 0; i <= preview; i++) generateNext();
}
public void reset() {
/*
for(Pos p : this.blocks) {
this.world.setBlock(p, Block.AIR);
}
this.blocks.clear();
*/ */
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.player.teleport(this.checkpoint.add(0.5, 1, 0.5));
this.combo = 0; this.combo = 0;
this.player.showTitle(Title.title(Component.text(""), Component.text(""))); this.player.clearTitle();
this.player.playSound(RESET_SOUND);
//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;
} }
} }
@@ -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);
}
}