From de6b987ffd870009d5f3099a49f253016408e5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 8 Oct 2026 00:23:17 +0200 Subject: [PATCH] Drop a random joker on every diamond block and simplify the generator around an explicit course position --- .../minecraft/endlessjumper/BlockMath.java | 9 - .../endlessjumper/EndlessJumper.java | 101 +++--- .../mhsl/minecraft/endlessjumper/Jokers.java | 89 +++-- .../minecraft/endlessjumper/ScoreTab.java | 13 +- .../endlessjumper/TerrainGenerator.java | 12 - .../endlessjumper/generator/BlockPalette.java | 17 +- .../generator/JumpGenerator.java | 316 ++++++++++-------- 7 files changed, 259 insertions(+), 298 deletions(-) delete mode 100644 src/main/java/eu/mhsl/minecraft/endlessjumper/BlockMath.java delete mode 100644 src/main/java/eu/mhsl/minecraft/endlessjumper/TerrainGenerator.java diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/BlockMath.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/BlockMath.java deleted file mode 100644 index c7edf7d..0000000 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/BlockMath.java +++ /dev/null @@ -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()); - } -} diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java index 3c1e56c..925767a 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java @@ -8,6 +8,7 @@ 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; @@ -17,112 +18,94 @@ 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.event.player.PlayerUseItemOnBlockEvent; -import net.minestom.server.item.ItemStack; +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.item.ItemStack; import net.minestom.server.timer.TaskSchedule; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class EndlessJumper { + private static final Pos START = new Pos(0, 100, 0); + private static final Map games = new ConcurrentHashMap<>(); + public static void main(String[] args) throws IOException { MinecraftServer server = MinecraftServer.init(new Auth.Velocity(readForwardingSecret())); InstanceManager instanceManager = MinecraftServer.getInstanceManager(); GlobalEventHandler eventHandler = MinecraftServer.getGlobalEventHandler(); PlayerSkin skin = PlayerSkin.fromUsername("Notch"); - HashMap games = new HashMap<>(); - eventHandler.addListener(AsyncPlayerConfigurationEvent.class, event -> { final Player p = event.getPlayer(); - p.setRespawnPoint(new Pos(0.5,101.5,0.5)); + p.setRespawnPoint(START.add(0.5, 1.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.setBlock(START, Block.DIAMOND_BLOCK); world.defaultClock().pause(); world.setTime(18000); p.setSkin(skin); - event.setSpawningInstance(world); - MinecraftServer.getSchedulerManager().scheduleTask( - () -> games.put(p, new JumpGenerator(startingAt, p, world)), - TaskSchedule.seconds(3), - TaskSchedule.stop() - ); + // an den Spieler gebunden: läuft nicht mehr, wenn er vorher geht + p.scheduler().buildTask(() -> games.put(p, new JumpGenerator(START, p, world))) + .delay(TaskSchedule.seconds(3)) + .schedule(); + }); + + eventHandler.addListener(PlayerSpawnEvent.class, event -> { + if(!event.isFirstSpawn()) return; + event.getPlayer().setCanPickupItem(true); + ScoreTab.join(event.getPlayer()); + Jokers.giveStart(event.getPlayer()); }); eventHandler.addListener(PlayerMoveEvent.class, event -> { - Player p = event.getPlayer(); - - if(!games.containsKey(p)) { - event.setCancelled(true); - return; - } - - JumpGenerator generator = games.get(p); - - generator.checkFall(); - - 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()); }); - // nur Brücken- und Rettungsblöcke dürfen abgebaut werden (Notausgang), Sprungblöcke nie + // nur Brücken- und Rettungsblöcke dürfen abgebaut werden (Notausgang), Streckenblöcke nie eventHandler.addListener(PlayerBlockBreakEvent.class, event -> { JumpGenerator generator = games.get(event.getPlayer()); - if(generator == null || !generator.isBreakable(new Pos(event.getBlockPosition()))) event.setCancelled(true); + if(generator == null || !generator.isBreakable(event.getBlockPosition())) 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)); - // Rechtsklick auf Luft und auf Blöcke lösen beide aus; kurze Sperre gegen doppelte Nutzung - Map lastJokerUse = new HashMap<>(); - java.util.function.BiConsumer useJoker = (p, item) -> { - JumpGenerator generator = games.get(p); - if(generator == null || !Jokers.isBridge(item)) return; - long now = System.currentTimeMillis(); - if(now - lastJokerUse.getOrDefault(p, 0L) < 500) return; - lastJokerUse.put(p, now); - if(generator.useBridge()) Jokers.takeBridge(p); - }; - eventHandler.addListener(PlayerUseItemEvent.class, event -> useJoker.accept(event.getPlayer(), event.getItemStack())); - eventHandler.addListener(PlayerUseItemOnBlockEvent.class, event -> useJoker.accept(event.getPlayer(), event.getItemStack())); + // Rechtsklick auf Luft und auf Blöcke: useBridge() baut beim zweiten Event nichts doppelt + eventHandler.addListener(PlayerUseItemEvent.class, event -> useJoker(event.getPlayer(), event.getItemStack())); + eventHandler.addListener(PlayerUseItemOnBlockEvent.class, event -> useJoker(event.getPlayer(), event.getItemStack())); - eventHandler.addListener(PlayerSpawnEvent.class, event -> { - if(!event.isFirstSpawn()) return; - ScoreTab.join(event.getPlayer()); - Jokers.giveStart(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 -> { - games.remove(event.getPlayer()); - lastJokerUse.remove(event.getPlayer()); - ScoreTab.leave(event.getPlayer()); + Player p = event.getPlayer(); + games.remove(p); + ScoreTab.leave(p); + // jede Partie hat eine eigene Welt: nach dem Verlassen freigeben + Instance world = p.getInstance(); + if(world != null) MinecraftServer.getSchedulerManager().scheduleNextTick(() -> instanceManager.unregisterInstance(world)); }); server.start("0.0.0.0", 25501); + } + private static void useJoker(Player player, ItemStack item) { + JumpGenerator generator = games.get(player); + if(generator != null && Jokers.BRIDGE.matches(item) && generator.useBridge()) Jokers.BRIDGE.take(player); } // Wie bei Velocity: Secret aus forwarding.secret im Arbeitsverzeichnis, alternativ per Umgebungsvariable diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/Jokers.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/Jokers.java index 8246f0b..df15c34 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/Jokers.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/Jokers.java @@ -2,79 +2,68 @@ 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. + * Joker-Items in der Hotbar, die Anzahl ist die Stapelgröße. * Brücke: Rechtsklick baut einen Weg bis zum nächsten Checkpoint. * Totem: rettet automatisch beim Runterfallen (Plattform mit Leiter). */ -public class Jokers { - public static final int BRIDGE_SLOT = 0; - public static final int TOTEM_SLOT = 8; +public enum Jokers { + BRIDGE(0, Material.BRICK, "Brücke", NamedTextColor.AQUA, "Rechtsklick: Weg bis zum nächsten Checkpoint"), + TOTEM(8, Material.TOTEM_OF_UNDYING, "Rettungstotem", NamedTextColor.GOLD, "Rettet dich automatisch beim Runterfallen"); - private static final Material BRIDGE = Material.BRICK; - private static final Material TOTEM = Material.TOTEM_OF_UNDYING; + private final int slot; + private final String name; + private final ItemStack item; - public static void giveStart(Player player) { - player.getInventory().setItemStack(BRIDGE_SLOT, bridge(1)); - player.getInventory().setItemStack(TOTEM_SLOT, totem(1)); + Jokers(int slot, Material material, String name, TextColor color, String lore) { + 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 static boolean isBridge(ItemStack item) { - return item.material() == BRIDGE; + public ItemStack item() { + return this.item; } - public static void addBridge(Player player) { - add(player, BRIDGE_SLOT, bridge(1)); - player.sendMessage(Component.text("+1 Brücke", NamedTextColor.GOLD)); + public boolean matches(ItemStack stack) { + return stack.material() == this.item.material(); } - public static void addTotem(Player player) { - add(player, TOTEM_SLOT, totem(1)); - player.sendMessage(Component.text("+1 Rettungstotem", NamedTextColor.GOLD)); + 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)); } - public static boolean takeBridge(Player player) { - return take(player, BRIDGE_SLOT, BRIDGE); - } - - public static boolean takeTotem(Player player) { - return take(player, TOTEM_SLOT, TOTEM); - } - - private static void add(Player player, int slot, ItemStack item) { - ItemStack current = player.getInventory().getItemStack(slot); - if(current.material() == item.material()) { - player.getInventory().setItemStack(slot, current.withAmount(current.amount() + 1)); - } else { - player.getInventory().setItemStack(slot, item); - } - } - - private static boolean take(Player player, int slot, Material material) { - ItemStack current = player.getInventory().getItemStack(slot); - if(current.material() != material || current.amount() <= 0) return false; - player.getInventory().setItemStack(slot, current.amount() > 1 ? current.withAmount(current.amount() - 1) : ItemStack.AIR); + /** 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; } - private static ItemStack bridge(int amount) { - return ItemStack.builder(BRIDGE) - .amount(amount) - .customName(Component.text("Brücke", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false)) - .lore(Component.text("Rechtsklick: Weg bis zum nächsten Checkpoint", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)) - .build(); + public static void giveStart(Player player) { + for(Jokers joker : values()) player.getInventory().setItemStack(joker.slot, joker.item); } - private static ItemStack totem(int amount) { - return ItemStack.builder(TOTEM) - .amount(amount) - .customName(Component.text("Rettungstotem", NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)) - .lore(Component.text("Rettet dich automatisch beim Runterfallen", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)) - .build(); + public static Jokers random(Random random) { + return values()[random.nextInt(values().length)]; + } + + public static Jokers of(ItemStack stack) { + for(Jokers joker : values()) { + if(joker.matches(stack)) return joker; + } + return null; } } diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/ScoreTab.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/ScoreTab.java index 2b3fc03..933b872 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/ScoreTab.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/ScoreTab.java @@ -4,31 +4,24 @@ import net.minestom.server.entity.Player; import net.minestom.server.network.packet.server.play.ScoreboardObjectivePacket; import net.minestom.server.scoreboard.TabList; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - /** * 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); - private static final Map scores = new ConcurrentHashMap<>(); public static void join(Player player) { TAB_LIST.addViewer(player); - scores.put(player, 0); // dem neuen Spieler auch die Scores aller anderen zeigen - scores.forEach(TAB_LIST::updateScore); + TAB_LIST.getViewers().forEach(viewer -> TAB_LIST.updateScore(viewer, viewer.getLevel())); } public static void update(Player player, int score) { - if(!scores.containsKey(player)) return; - scores.put(player, score); - TAB_LIST.updateScore(player, score); + if(TAB_LIST.getViewers().contains(player)) TAB_LIST.updateScore(player, score); } public static void leave(Player player) { - scores.remove(player); TAB_LIST.removeViewer(player); } } diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/TerrainGenerator.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/TerrainGenerator.java deleted file mode 100644 index 0c1b452..0000000 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/TerrainGenerator.java +++ /dev/null @@ -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) { - - } -} diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java index 1af51b5..85f83fa 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java @@ -13,7 +13,7 @@ public class BlockPalette { public record Stage(int from, String name, List blocks) { } - public static final List STAGES = List.of( + private static final List STAGES = List.of( new Stage(0, "Wiese", List.of( Block.GRASS_BLOCK, Block.GRASS_BLOCK, Block.MOSS_BLOCK, Block.PODZOL, Block.ROOTED_DIRT )), @@ -59,19 +59,10 @@ public class BlockPalette { ); public static Stage stageAt(int index) { - Stage current = STAGES.getFirst(); - for(Stage stage : STAGES) { - if(index >= stage.from()) current = stage; + for(Stage stage : STAGES.reversed()) { + if(index >= stage.from()) return stage; } - return current; - } - - /** Liefert die Stufe, die genau bei diesem Index beginnt, sonst null. */ - public static Stage stageStartingAt(int index) { - for(Stage stage : STAGES) { - if(stage.from() == index) return stage; - } - return null; + return STAGES.getFirst(); } public static Block getRandom(int index, Random random) { diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java index cc61221..eeae989 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java @@ -1,39 +1,47 @@ package eu.mhsl.minecraft.endlessjumper.generator; -import net.kyori.adventure.sound.Sound; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.TextColor; -import net.kyori.adventure.title.Title; import eu.mhsl.minecraft.endlessjumper.Jokers; 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.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.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 final Player player; - private final InstanceContainer world; - private final ArrayList blocks = new ArrayList<>(); - private Pos checkpoint; - private Pos starting; + private record Step(Pos pos, Block block) { + } - private int combo = 0; - // Block, von dem der nächste Sprung startet (bestimmt, ob ein Sprung nach oben möglich ist) - private Block lastBlock = Block.DIAMOND_BLOCK; - - private final int preview = 3; + 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 Block BRIDGE_BLOCK = Block.WHITE_STAINED_GLASS; @@ -41,68 +49,130 @@ public class JumpGenerator { private static final Block RESCUE_BLOCK = Block.OAK_PLANKS; private static final int RESCUE_DEPTH = 6; + 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 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 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 List course = new ArrayList<>(); + private final Map courseIndex = new HashMap<>(); + private int standing = 0; + private int combo = 0; + private Pos checkpoint; + // bis zu diesem Index wurde per Brücke gebaut: diese Blöcke zählen nicht zur Combo + private int bridgeEnd = -1; + // Rettung durch das Totem: Plattform mit Leiter unter dem Block, von dem man gefallen ist private final Map rescueBlocks = new HashMap<>(); private Pos rescueSafe; - private int rescueFloorY; - - public JumpGenerator(Pos starting, Player player, InstanceContainer world) { + public JumpGenerator(Pos start, Player player, Instance world) { this.player = player; this.world = world; - this.starting = starting; - generateStart(starting); + this.checkpoint = start; + this.addStep(new Step(start, Block.DIAMOND_BLOCK)); + // nacheinander animieren, damit jeder Block aus seinem bereits stehenden Vorgänger wächst + this.ensureGenerated(PREVIEW + 1, BlockTransition.TOTAL_TICKS); } - public void generateNext() { - boolean standingTenth = (this.blocks.size()-this.preview-1) % 10 == 0; + private static boolean isCheckpoint(int index) { + return index % 10 == 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)))); - int score = Math.max(0, this.blocks.size()-this.preview-1); - this.player.setLevel(score); - ScoreTab.update(this.player, score); + private static boolean isMilestone(int index) { + return index % 100 == 0; + } - if(score > 0 && score % 100 == 0) Jokers.addBridge(this.player); - if(score > 0 && score % 200 == 0) Jokers.addTotem(this.player); + private static double height(Block block) { + return block.collisionShape().relativeEnd().y(); + } - this.generateBlock(0); + private Pos landing() { + return this.course.get(this.standing + 1).pos(); + } - if(standingTenth) { - Pos current = this.blocks.get(this.blocks.size()-this.preview-2); - 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)); + /** 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(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))); + } + + if(isCheckpoint(index)) { + this.checkpoint = this.course.get(index).pos(); + 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)); } // Abschnitt dauerhaft in der Actionbar (Titel werden von der Combo-Anzeige überschrieben) - BlockPalette.Stage reachedStage = BlockPalette.stageStartingAt(score); - if(reachedStage != null && reachedStage.from() > 0) { - this.player.playSound(Sound.sound(SoundEvent.UI_TOAST_CHALLENGE_COMPLETE, Sound.Source.MASTER, 0.4f, 1f)); - this.player.sendActionBar(Component.text(reachedStage.name(), TextColor.color(255, 170, 0))); + BlockPalette.Stage stage = BlockPalette.stageAt(index); + if(stage != BlockPalette.stageAt(previous)) { + this.player.playSound(STAGE_SOUND); + this.player.sendActionBar(Component.text(stage.name(), NamedTextColor.GOLD)); } else { - this.player.sendActionBar(Component.text(BlockPalette.stageAt(score).name(), TextColor.color(170, 170, 170))); + this.player.sendActionBar(Component.text(stage.name(), NamedTextColor.GRAY)); } - this.combo++; + + this.ensureGenerated(index + PREVIEW + 1, 0); } - /** Erzeugt den nächsten Sprungblock ohne Rückmeldung an den Spieler (Combo, Titel, Sounds). */ - private void generateBlock(int animationDelayTicks) { - int index = this.blocks.size(); - Random r = new Random(); - Pos previous = blocks.getLast(); - Pos target = previous.add(this.nextOffset(index, r)); - Block block = index % 100 == 0 ? Block.DIAMOND_BLOCK : index % 10 == 0 ? Block.GOLD_BLOCK : BlockPalette.getRandom(index, r); - this.lastBlock = block; - BlockTransition.animate(this.world, previous, target, block, animationDelayTicks, () -> { + private void ensureGenerated(int upTo, int stepTicks) { + int delay = 0; + while(this.course.size() <= upTo) { + this.generateStep(delay, null); + delay += stepTicks; + } + } + + private void addStep(Step step) { + this.courseIndex.put(BlockKey.of(step.pos()), this.course.size()); + 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.course.size(); + 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)) this.dropJoker(target); + if(afterArrival != null) afterArrival.run(); }); - this.blocks.add(target); + } + + /** Auf jedem Diamantblock 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)); } /** @@ -111,55 +181,50 @@ public class JumpGenerator { * Anteil an 4er-Sprüngen, seitlichen Versätzen und Sprüngen nach oben bis zum bisherigen * Niveau und bleiben danach dort. */ - private Pos nextOffset(int index, Random r) { + private Vec nextOffset(int index, Block from, Block to) { double difficulty = Math.min(1.0, (double) index / FULL_DIFFICULTY_AT); - int dz = r.nextDouble() < 0.1 + 0.4 * difficulty ? 4 : 3; - - int dx = r.nextDouble() < 0.1 + 0.57 * difficulty ? (r.nextBoolean() ? 1 : -1) : 0; + 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 = r.nextDouble(); + 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, 13/16 hoch) ist ein Block höher nicht erreichbar - if(dy == 1 && this.lastBlock.collisionShape().relativeEnd().y() < 1) dy = 0; + // die Oberkante darf höchstens einen Block höher liegen (z. B. vom Endportalrahmen aus) + if(dy == 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 Pos(dx, dy, dz); + return new Vec(dx, dy, dz); } /** - * Joker „Brücke“: erzeugt die Sprungblöcke bis zum nächsten Checkpoint und verbindet sie - * mit einem begehbaren Weg. Die übersprungenen Blöcke zählen nicht zur Combo. + * Joker „Brücke“: erzeugt die Strecke bis zum nächsten Checkpoint und verbindet sie mit einem + * begehbaren Weg. Diese Blöcke zählen nicht zur Combo. Mehrfacher Aufruf baut nichts doppelt. */ public boolean useBridge() { - if(this.rescueSafe != null) return false; + int nextCheckpoint = (this.standing / 10 + 1) * 10; + if(this.rescueSafe != null || this.bridgeEnd >= nextCheckpoint) return false; + this.bridgeEnd = nextCheckpoint; - int standing = this.blocks.size() - this.preview - 2; - int nextCheckpoint = (standing / 10 + 1) * 10; - - // so weit vorgenerieren, dass der Checkpoint der nächste Landeblock ist + // bereits stehende Abschnitte sofort, neue Blöcke bekommen ihren Weg bei Ankunft + for(int i = this.standing + 1; i < this.course.size() && i <= nextCheckpoint; i++) { + this.buildPath(this.course.get(i - 1).pos(), this.course.get(i).pos()); + } int delay = 0; - while(this.blocks.size() - this.preview - 1 < nextCheckpoint) { - this.generateBlock(delay); + while(this.course.size() <= nextCheckpoint) { + Pos from = this.course.getLast().pos(); + int index = this.course.size(); + this.generateStep(delay, () -> this.buildPath(from, this.course.get(index).pos())); delay += BRIDGE_STEP_TICKS; } - for(int i = standing; i < nextCheckpoint; i++) { - Pos from = this.blocks.get(i); - Pos to = this.blocks.get(i + 1); - this.world.scheduler().buildTask(() -> this.buildPath(from, to)) - .delay(TaskSchedule.tick(1 + (i - standing) * BRIDGE_STEP_TICKS)) - .schedule(); - } - - this.player.playSound(Sound.sound(SoundEvent.BLOCK_AMETHYST_BLOCK_CHIME, Sound.Source.MASTER, 1f, 1f)); + this.player.playSound(BRIDGE_SOUND); return true; } @@ -175,56 +240,52 @@ public class JumpGenerator { } } - /** Wird bei jeder Bewegung aufgerufen: Rettung durch Totem oder Reset beim Runterfallen. */ - public void checkFall() { + private void checkFall() { double y = this.player.getPosition().y(); if(this.rescueSafe != null) { if(y >= this.rescueSafe.y() + 0.9) this.endRescue(); - else if(y < this.rescueFloorY - 3) { + else if(y < this.rescueSafe.blockY() - RESCUE_DEPTH - 3) { this.endRescue(); this.reset(); } return; } - if(y < this.getNextLanding().y() - 5 && y < this.checkpoint.y()) { - if(Jokers.takeTotem(this.player)) this.rescue(); + 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. + * 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.blocks.get(Math.max(0, this.blocks.size() - this.preview - 2)); + Pos safe = this.course.get(this.standing).pos(); int x = safe.blockX(); int z = safe.blockZ(); + int floorY = safe.blockY() - RESCUE_DEPTH; this.rescueSafe = safe; - this.rescueFloorY = safe.blockY() - RESCUE_DEPTH; for(int dx = -1; dx <= 1; dx++) { - for(int dz = -3; dz <= -1; dz++) this.placeRescue(new Pos(x + dx, this.rescueFloorY, z + dz), RESCUE_BLOCK); + 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 = this.rescueFloorY + 1; y <= safe.blockY(); y++) { + 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, this.rescueFloorY + 1, z - 1.5, 0, 0)); + this.player.teleport(new Pos(x + 0.5, floorY + 1, z - 1.5, 0, 0)); this.player.triggerStatus((byte) 35); - this.player.playSound(Sound.sound(SoundEvent.ITEM_TOTEM_USE, Sound.Source.MASTER, 0.6f, 1f)); + this.player.playSound(RESCUE_SOUND); } - /** - * Setzt einen Rettungsblock. Brückenglas darf überschrieben werden, sonst könnte ein früherer - * Brückenweg die Leiter von oben versperren. Sprungblöcke liegen nie im Rettungsbereich, da - * Sprünge immer mindestens 3 Blöcke nach vorne gehen und die Rettung nur dahinter baut. - */ + /** 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)) return; @@ -234,62 +295,27 @@ public class JumpGenerator { private void endRescue() { this.rescueSafe = null; - Map toRemove = new HashMap<>(this.rescueBlocks); + Map placed = Map.copyOf(this.rescueBlocks); this.rescueBlocks.clear(); - // nur entfernen, was noch unverändert steht (der Spieler darf Rettungsblöcke abbauen) - this.world.scheduler().buildTask(() -> toRemove.forEach((pos, block) -> { - if(this.world.getBlock(pos).compare(block)) this.world.setBlock(pos, Block.AIR); + removeLater(this.world, placed); + } + + /** Entfernt nur, was noch unverändert steht (Rettungsblöcke dürfen abgebaut werden). */ + private static void removeLater(Instance world, Map placed) { + world.scheduler().buildTask(() -> placed.forEach((pos, block) -> { + if(world.getBlock(pos).compare(block)) world.setBlock(pos, Block.AIR); })).delay(TaskSchedule.seconds(8)).schedule(); } - /** Sprungblöcke und ihr Licht sind geschützt, alles andere (Brücke, Rettung) darf abgebaut werden. */ - public boolean isBreakable(Pos pos) { - if(this.world.getBlock(pos).compare(Block.LIGHT)) return false; - for(Pos block : this.blocks) { - if(block.sameBlock(pos)) return false; - } - return true; + /** Streckenblöcke und ihr Licht sind geschützt, alles andere (Brücke, Rettung) darf abgebaut werden. */ + public boolean isBreakable(Point pos) { + return !this.world.getBlock(pos).compare(Block.LIGHT) && !this.courseIndex.containsKey(BlockKey.of(pos)); } - public Pos getNextLanding() { - return this.blocks.get(this.blocks.size()-(preview+1)); - } - - - public void generateStart(Pos start) { - this.starting = start; - this.checkpoint = start; - this.blocks.add(this.starting); - this.world.setBlock(this.starting, Block.DIAMOND_BLOCK); - // nacheinander animieren, damit jeder Block aus seinem bereits stehenden Vorgänger wächst - for(int i = 0; i <= preview; i++) generateBlock(i * BlockTransition.TOTAL_TICKS); - } - - public void reset() { - /* - for(Pos p : this.blocks) { - this.world.setBlock(p, Block.AIR); - } - this.blocks.clear(); - */ + 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 getBlocks() { - return blocks; - } - - public int getPreview() { - return preview; + this.player.clearTitle(); + this.player.playSound(RESET_SOUND); } }