Drop a random joker on every diamond block and simplify the generator around an explicit course position

This commit is contained in:
MineTec committed 2026-10-08 00:23:17 +02:00
1 parent ef1662b144
commit de6b987ffd
7 files changed
+259 -298

No files matched your search

@@ -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());
}
}
@@ -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<Player, JumpGenerator> 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<Player, JumpGenerator> games = new HashMap<>();
eventHandler.addListener(AsyncPlayerConfigurationEvent.class, event -> {
final Player p = event.getPlayer();
p.setRespawnPoint(new Pos(0.5,101.5,0.5));
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<Player, Long> lastJokerUse = new HashMap<>();
java.util.function.BiConsumer<Player, ItemStack> 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
@@ -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;
}
}
@@ -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<Player, Integer> 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);
}
}
@@ -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) {
}
}
@@ -13,7 +13,7 @@ public class BlockPalette {
public record Stage(int from, String name, List<Block> blocks) {
}
public static final List<Stage> STAGES = List.of(
private static final List<Stage> 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) {
@@ -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<Pos> 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<Step> course = new ArrayList<>();
private final Map<BlockKey, Integer> 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<Pos, Block> 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<Pos, Block> toRemove = new HashMap<>(this.rescueBlocks);
Map<Pos, Block> 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<Pos, Block> 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<Pos> getBlocks() {
return blocks;
}
public int getPreview() {
return preview;
this.player.clearTitle();
this.player.playSound(RESET_SOUND);
}
}